Android UI框架实战:底部导航与Fragment懒加载复用方案
简介这份资源面向Android初中级开发者聚焦应用整体UI框架的搭建与落地围绕自定义底部任务栏、Fragment灵活切换以及项目架构设计三个核心问题展开适合需要从零梳理界面骨架或优化现有项目结构的读者参考。压缩包共214个文件约5.25MB以128个png界面素材、24个xml布局与配置、18个java源码及30个class文件为主体另含jar依赖、apk成品与dex等覆盖从资源到可运行工程的完整链路。已有502人学习下载可作为实践参照。资源中可见TabView、FragmentUtils、ServiceFragment等类与多套Activity实现配合DialogUtils等工具类能帮助读者理解底部导航定制、FragmentManager事务管理与界面过渡的写法并对照MVP或MVVM思路体会解耦架构的组织方式从而快速搭出结构清晰、便于维护的UI框架。1. 从一堆散装 Activity 到一套能复用的壳这个 Android UI 框架到底解决什么接手一个迭代了两三年的 Android 项目最怕的不是业务逻辑复杂而是打开工程一看十几个 Activity 各自写了一套底部导航有的用 LinearLayout 手搓有的用 RadioGroup还有的干脆在布局里塞了五个 ImageView 加监听。改一个图标颜色得翻遍整个工程。这个「android 项目整体 UI 框架」要解决的正是这类问题——它把底部导航、页面容器、Fragment 切换、状态栏适配这些重复劳动收敛成一套可复用的壳业务方只需要关心每个 Tab 里放什么内容。适合谁适合手上有一个中小型 Android 工程、想统一 UI 骨架又不想引入重型组件库的开发者也适合刚接触 android 框架、想看懂一个完整工程怎么组织源码的人。它不追求花哨追求的是「换一个项目能直接搬过去用」。2. 框架骨架拆解从 Application 到 MainActivity 的启动链路2.1 工程目录与模块职责拿到源码先别急着点运行花十分钟把目录结构过一遍后面改代码会省很多事。这类 UI 框架工程通常采用单模块结构包名按职责分层而不是按页面分层。常见的组织方式是这样app/ ├── src/main/ │ ├── java/com/example/uiframework/ │ │ ├── base/ # BaseActivity、BaseFragment、BasePresenter │ │ ├── ui/ │ │ │ ├── main/ # MainActivity 底部导航容器 │ │ │ ├── home/ # 首页 Tab 内容 │ │ │ ├── discover/ # 发现 Tab 内容 │ │ │ └── mine/ # 我的 Tab 内容 │ │ ├── widget/ # 自定义 BottomBar、状态栏工具 │ │ └── util/ # 屏幕、颜色、资源工具类 │ ├── res/ │ │ ├── layout/ # activity_main、fragment_*.xml │ │ ├── menu/ # 底部导航的菜单定义 │ │ └── values/ # colors、dimens、styles │ └── AndroidManifest.xml └── build.gradle按职责分层的好处是base 包里的东西几乎不随业务变化ui 包按 Tab 拆分新增一个 Tab 就是新增一个子包加一个 Fragment不用动主容器。我一般会先看base/和ui/main/这两个目录它们决定了整个框架的骨架其余都是填充物。如果源码里还带了widget/BottomBar.java那说明底部导航是自绘的不依赖第三方库这也是这类框架能「搬走就用」的关键。2.2 启动链路Application 初始化与 MainActivity 装配框架的启动链路决定了全局配置在哪里落地。典型做法是在自定义 Application 里做一次性的初始化比如屏幕适配参数、全局异常捕获、状态栏工具的单例准备。看下面这段public class UIApplication extends Application { private static UIApplication instance; Override public void onCreate() { super.onCreate(); instance this; // 初始化屏幕适配基准常见做法是以 360dp 宽为基准 ScreenUtil.init(this, 360); // 全局捕获未处理异常方便定位崩溃 CrashHandler.getInstance().init(this); } public static UIApplication getInstance() { return instance; } }ScreenUtil.init的第二个参数是设计稿基准宽度单位 dp。如果你的设计稿是 375 宽就改成 375否则所有尺寸会整体偏移。CrashHandler不是必须的但在一线项目里我强烈建议保留它能把崩溃堆栈写到本地文件省去很多「用户说闪退但我复现不了」的扯皮。MainActivity 的职责很克制它只负责装配底部导航和 Fragment 容器不写任何业务逻辑。装配过程一般分三步——找控件、初始化 Fragment 列表、绑定切换监听。这个「主容器只做容器」的原则是后面能轻松加 Tab 的前提。2.3 底部导航容器的实现方式底部导航有两种常见实现一种是直接用BottomNavigationView另一种是自绘LinearLayout加若干TextView/ImageView。这个框架走的是自绘路线原因是自绘对图标大小、文字间距、选中态动画的控制更细也不受 Material 版本差异影响。核心代码大致长这样public class BottomBar extends LinearLayout { private final ListTabItem tabs new ArrayList(); private OnTabSelectedListener listener; private int currentIndex 0; public BottomBar(Context context) { super(context); setOrientation(HORIZONTAL); } // 添加一个 Tab图标、选中图标、文字 public void addTab(int normalIcon, int selectedIcon, String title) { TabItem item new TabItem(getContext()); item.bind(normalIcon, selectedIcon, title); final int index tabs.size(); item.setOnClickListener(v - select(index)); tabs.add(item); addView(item, new LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)); } public void select(int index) { if (index currentIndex) return; tabs.get(currentIndex).setSelected(false); tabs.get(index).setSelected(true); currentIndex index; if (listener ! null) listener.onTabSelected(index); } public void setOnTabSelectedListener(OnTabSelectedListener l) { this.listener l; } }LayoutParams(0, MATCH_PARENT, 1f)里的1f是权重保证每个 Tab 等宽平分。select方法里先判断index currentIndex再返回是为了避免重复点击同一个 Tab 时触发无谓的 Fragment 重建——这个细节很多手搓导航都会漏掉结果就是用户连点两下首页页面闪一下。OnTabSelectedListener把选中事件抛给外部容器本身不关心 Fragment 怎么切职责边界很清楚。3. 页面切换与状态管理Fragment 复用和懒加载怎么落地3.1 Fragment 的添加与隐藏策略底部导航切 Tab 时Fragment 有两种处理方式replace和add hide/show。前者每次切换都重建 Fragment状态丢失、动画生硬后者把 Fragment 都加进去用 hide/show 控制可见性状态能保留。这个框架用的是后者代码集中在 MainActivity 里private void switchFragment(int index) { FragmentTransaction ft getSupportFragmentManager().beginTransaction(); Fragment target fragments.get(index); if (!target.isAdded()) { // 首次添加同时隐藏当前显示的 Fragment ft.hide(currentFragment); ft.add(R.id.container, target, FRAGMENT_TAGS[index]); } else { // 已添加过只做显示隐藏切换 ft.hide(currentFragment).show(target); } ft.commitAllowingStateLoss(); currentFragment target; }commitAllowingStateLoss而不是commit是因为底部导航切换可能发生在 Activity 已经 onSaveInstanceState 之后比如快速连点用commit会抛IllegalStateException。这是血泪经验线上崩溃日志里这类问题占比不低。FRAGMENT_TAGS是给每个 Fragment 打的标签配合findFragmentByTag可以在 Activity 重建后找回实例避免重复添加。3.2 懒加载onHiddenChanged 的正确用法用 hide/show 策略后Fragment 的onResume不会在每次显示时重新触发所以「第一次可见才加载数据」的逻辑不能写在onResume里得靠onHiddenChanged。框架在 BaseFragment 里做了统一封装public abstract class BaseFragment extends Fragment { private boolean isFirstVisible true; Override public void onHiddenChanged(boolean hidden) { super.onHiddenChanged(hidden); if (!hidden isFirstVisible) { isFirstVisible false; onFirstVisible(); } } // 子类实现首次可见时加载数据 protected abstract void onFirstVisible(); }isFirstVisible这个标志位保证onFirstVisible只执行一次。子类里把网络请求、列表初始化放进去就不会出现「切回来又请求一遍」的问题。注意如果 Fragment 是通过 ViewPager 承载的这套逻辑要换成setUserVisibleHint或ViewPager2的onPageSelected两者不能混用混用会导致懒加载失效——这是我在两个项目里都踩过的坑。3.3 状态栏与沉浸式适配UI 框架绕不开状态栏。这个工程在widget/StatusBarUtil里封装了状态栏高度获取、颜色设置、沉浸式开关。核心是拿到状态栏高度后给根布局加顶部 padding避免内容被状态栏遮挡public static void setStatusBarColor(Activity activity, int color) { Window window activity.getWindow(); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); window.setStatusBarColor(color); // 根布局顶部留出状态栏高度 View root activity.findViewById(android.R.id.content); root.setPadding(0, getStatusBarHeight(activity), 0, 0); } public static int getStatusBarHeight(Context context) { int resId context.getResources() .getIdentifier(status_bar_height, dimen, android); return resId 0 ? context.getResources().getDimensionPixelSize(resId) : 0; }getIdentifier通过资源名反射拿系统尺寸比硬编码 24dp 靠谱因为不同厂商的状态栏高度差异很大。setPadding这一步要注意如果根布局本身已经有 padding直接覆盖会丢掉原有值稳妥做法是先读root.getPaddingTop()再累加。这个细节在源码里如果没处理二次开发时得自己补上。4. 跑起来环境、依赖与首次运行的关键步骤4.1 环境准备与依赖配置这类工程对环境的门槛不高但版本对不上照样跑不起来。常见配置是 Gradle 7.x 加 AGP 7.xcompileSdk 33 左右minSdk 21。打开build.gradle先确认三件事仓库地址、依赖版本、签名配置。依赖部分通常只有 support 库或 AndroidX 加少量工具库不会有重型第三方android { compileSdk 33 defaultConfig { applicationId com.example.uiframework minSdk 21 targetSdk 33 versionCode 1 versionName 1.0 } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation androidx.fragment:fragment:1.6.1 implementation com.google.android.material:material:1.9.0 }sourceCompatibility设成 1.8 是因为代码里用了 lambda比如item.setOnClickListener(v - ...)低于 1.8 会编译报错。如果导入后提示找不到 AndroidX 包检查gradle.properties里有没有android.useAndroidXtrue这是从旧工程迁移过来最常见的翻车点。4.2 首次运行的验证清单装到模拟器或真机后别只看首页能不能显示按下面几步走一遍能提前发现大部分问题依次点击每个底部 Tab确认 Fragment 正常切换且不重叠。切到第二个 Tab 再切回第一个确认页面状态保留比如列表滚动位置。连点同一个 Tab 五次确认不闪屏、不崩溃。旋转屏幕确认当前 Tab 不丢失、不重复添加。进入后台再回前台确认数据没有重复请求。第 4 步是重点add hide/show策略在配置变更时最容易出问题如果旋转后出现两个 Fragment 叠在一起说明onCreate里没有判断savedInstanceState null得补上这个判断再恢复 Fragment。4.3 常见报错与定位方法跑不起来时先看 Logcat 里的第一个Caused by那才是根因。几个高频报错ClassNotFoundException多半是依赖没同步点一下 Sync ProjectInflateException通常是布局里自定义 View 的全类名写错或者构造函数少了(Context, AttributeSet)那个重载IllegalStateException: Can not perform this action after onSaveInstanceState就是前面说的commit问题换成commitAllowingStateLoss。定位这类问题我习惯先看崩溃发生在哪个生命周期回调再对照 Fragment 事务的提交时机比盲目改代码快得多。5. 避坑与排查底部导航框架最容易翻车的五个地方5.1 现象切 Tab 后页面白屏日志无异常原因通常是 Fragment 被hide了但目标 Fragment 没show或者add时容器 id 写错。解决在switchFragment里加一行日志打印target.isAdded()和容器 id确认事务对象和布局里的FrameLayout是同一个。另外检查commitAllowingStateLoss之后有没有立刻做耗时操作事务是异步执行的紧接着读 Fragment 状态可能拿到旧值。5.2 现象旋转屏幕后底部导航选中态错乱原因是 Activity 重建时 BottomBar 的currentIndex被重置为 0但 FragmentManager 恢复的是旋转前的 Fragment。解决在onCreate里从savedInstanceState读回当前索引或者在onSaveInstanceState里存currentIndex重建后调一次bottomBar.select(savedIndex)但不要触发 Fragment 重建。这个坑我在三个项目里见过属于框架级问题必须在 BaseActivity 里统一处理。5.3 现象快速连点 Tab 导致 Fragment 重叠原因是事务提交是异步的连点时前一个事务还没执行完后一个事务又基于旧的currentFragment计算 hide 目标。解决给切换加一个短时间防抖比如 300ms 内忽略重复点击或者用executePendingTransactions()强制同步。防抖更稳妥代码也好维护。5.4 现象状态栏颜色在部分机型上不生效原因是setStatusBarColor在 Android 5.0 以下无效且部分定制系统会忽略这个调用。解决先判断Build.VERSION.SDK_INT 21低版本用半透明黑色状态栏兜底对定制系统改用给根布局加背景色的方式模拟不要死磕系统 API。这是玄学问题多机型测试比看文档有用。5.5 现象懒加载在 ViewPager 场景下失效原因是onHiddenChanged只在 hide/show 时触发ViewPager 的页面切换走的是setUserVisibleHint两套机制不互通。解决BaseFragment 里同时重写两个方法用一个统一的onFirstVisible出口内部做去重。如果用的是 ViewPager2就监听onPageSelected回调别再用setUserVisibleHint那个方法在新版本里已经废弃。6. 二次开发把框架改成自己项目的壳拿到这套框架后真正的工作量在「改」而不是「跑」。我的习惯是先做减法再做加法把示例里的 home/discover/mine 三个 Tab 删到只剩一个确认主容器还能正常跑再按自己的业务往里加。加 Tab 的标准流程是三步——在BottomBar里addTab传图标和文字、在MainActivity的 Fragment 列表里加一个实例、新建对应的 Fragment 继承BaseFragment实现onFirstVisible。三步之外不要动主容器动了就破坏了框架的边界。改造时有两个地方值得优先投入。第一是底部导航的配置化把 Tab 的图标、文字、Fragment 类名抽到一个数组或配置文件里用循环批量addTab这样以后加 Tab 只改一处private static final TabConfig[] TABS { new TabConfig(R.drawable.ic_home, R.drawable.ic_home_sel, 首页, HomeFragment.class), new TabConfig(R.drawable.ic_find, R.drawable.ic_find_sel, 发现, DiscoverFragment.class), new TabConfig(R.drawable.ic_mine, R.drawable.ic_mine_sel, 我的, MineFragment.class), }; private void initTabs() { for (int i 0; i TABS.length; i) { TabConfig c TABS[i]; bottomBar.addTab(c.normalIcon, c.selectedIcon, c.title); // 反射创建 Fragment避免手写 if-else fragments.add((Fragment) c.clazz.newInstance()); } }TabConfig是个简单的数据类clazz.newInstance()用反射创建 Fragment 实例。反射有性能开销但只在启动时执行一次可以接受如果在意就换成工厂方法。这样改造后新增 Tab 只需要在TABS数组里加一行主容器代码一行不动。第二是给BaseFragment加统一的加载态和空态。业务 Fragment 里最烦的就是每个页面都写一遍「加载中、加载失败、空数据」三套布局。在 BaseFragment 里包一层FrameLayout提供showLoading()、showEmpty()、showContent()三个方法子类只管往 content 里塞内容。这个改造大概两百行代码但能让后续每个页面的代码量少三分之一。验证改造是否成功我一般用三个动作删掉一个 Tab 看会不会崩、旋转屏幕看选中态对不对、连续切换十次看内存有没有持续上涨用 Android Studio 的 Profiler 看 Fragment 实例数是否稳定。三个都过基本可以放心往业务里铺。说个自己的教训。早些年我图省事直接在 MainActivity 里用replace切 Fragment结果用户反馈「每次切回来列表都回到顶部」排查了半天才发现是 Fragment 被重建了。从那以后我每次接新工程第一件事就是确认底部导航用的是add hide/show还是replace并且强制在旋转屏幕和后台切换两个场景各走一遍。这套框架把这两个场景都处理好了但二次开发时如果你自己加了新容器记得把同样的验证补上。希望帮到你。本文还有配套的精品资源点击获取