ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android整体UI框架实战:单Activity多Fragment与底部导航

Android整体UI框架实战:单Activity多Fragment与底部导航

简介:这份资源面向Android初中级开发者,提供一套可直接参考的完整UI框架实现,重点解决底部导航栏定制、Fragment灵活切换与项目整体架构设计三类常见问题。压缩包共214个文件,约5.25MB,以128个png界面素材、24个xml布局与配置、18个java源码及30个class文件为主,另含apk安装包、jar依赖与dex文件,便于直接运行对照。已有502人学习下载,说明其参考价值得到一定验证。资源围绕自定义底部任务栏、FragmentManager与FragmentTransaction管理多视图切换、MVP或MVVM分层解耦展开,并涉及共享元素过渡动画与屏幕适配处理;结合源码中FragmentUtils、TabView、DialogUtils等工具类,读者可快速理解导航栏与页面切换的落地写法,并借鉴其目录组织与架构分层思路,用于搭建结构清晰、易维护的Android应用UI骨架。

1. 从“能跑就行”到“敢交付”:一套 Android 整体 UI 框架要解决什么

很多 Android 项目死在第一版之后:首页能跑、底部导航能点,但第二个页面一加,状态栏颜色对不上、Fragment 回退栈乱成一锅粥、深色模式一开整个界面像被泼了墨。问题不在某个控件写错,而在于一开始就没有一套“整体 UI 框架”——底部导航、顶部栏、页面容器、主题与状态管理各写各的,后期每加一个模块都在还债。这套方案要解决的就是这件事:给出一套可直接套用的 Android 整体 UI 框架,把底部导航、多页面容器、统一主题、沉浸式状态栏和返回栈管理收拢到一处,适合正在做安卓毕设、课程设计或需要快速搭出可交付 App 骨架的开发者。关键词里的安卓底部导航、android 框架、android 源码,本质都指向同一个诉求:要一份结构清楚、能改能扩的工程底座,而不是一堆散装代码。

2. 需求与选型:为什么是单 Activity 加多 Fragment

2.1 先把需求拆成四条硬约束

做整体 UI 框架,第一步不是打开编辑器,而是把“框架”这个词翻译成可验收的约束。我一般会先列四条:第一,底部导航必须支持 3~5 个一级入口,切换时页面状态要保留,不能每次重建;第二,每个一级入口内部还要能继续跳二级页面,返回键行为要符合用户直觉;第三,主题、状态栏、导航栏颜色要能一处配置全局生效,深色模式不能靠每个页面手写判断;第四,新增一个一级模块的成本要低到“加一个类、改一行配置”。这四条约束决定了后面所有技术选型,也决定了验收时拿什么标准去卡。

把这四条落到具体场景,就是学生做毕设时最常见的痛点:首页、消息、发现、我的四个 Tab,点进消息详情再返回,希望回到消息列表而不是首页;切到深色模式,底部导航的选中色和未选中色都要跟着变;状态栏在首页是透明沉浸式,在详情页又要变成带标题的实色。这些需求单看都不难,难的是它们同时存在且要长期可维护。框架的价值就在于把这些横切关注点提前收口,而不是等页面写到第八个再回头重构。

2.2 单 Activity 多 Fragment 的取舍

Android 整体 UI 框架绕不开一个经典选择:多 Activity 还是单 Activity 多 Fragment。多 Activity 上手快,但底部导航场景下会带来明显的割裂感——每个 Tab 一个 Activity,切换时任务栈管理复杂,共享状态要靠 Intent 或全局单例传,主题切换还得逐个 Activity 重建。单 Activity 多 Fragment 则把导航收拢到一个宿主里,底部导航只是切换 Fragment,返回栈由 FragmentManager 统一管理,主题和状态栏也只需在宿主 Activity 里处理一次。

我倾向单 Activity 多 Fragment,但不是无脑选。它的代价是 Fragment 生命周期更绕、页面间通信要走宿主或共享 ViewModel,初学者容易在onCreateView和onViewCreated之间翻车。所以框架里必须把宿主 Activity 的职责写死:只负责导航容器、底部栏、主题和返回栈,业务逻辑一律下沉到各 Fragment。这样职责边界清楚,后面加模块才不会互相污染。选型不是选最潮的,是选和需求约束最贴合的,这一点在毕设答辩时也最容易被追问。

2.3 依赖与工程结构定稿

技术栈上,这套框架保持轻量:Kotlin + AndroidX + Fragment + ViewBinding,底部导航用BottomNavigationView配合FragmentContainerView,不引入过重的第三方导航库,保证源码可读、可改、可讲清楚。工程结构按“宿主层 / 导航层 / 页面层 / 公共层”四层划分,宿主层放MainActivity,导航层放底部栏配置和 Fragment 切换逻辑,页面层按模块分包,公共层放主题、状态栏工具和基类。这个分层不是为了好看,是为了让“新增一个模块”这件事有明确的落点。

// app/build.gradle.kts 关键依赖,保持最小集 dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.11.0") implementation("androidx.fragment:fragment-ktx:1.6.2") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") }

这段依赖清单的取舍逻辑是:material提供BottomNavigationView和主题体系,fragment-ktx提供by viewModels()等便捷扩展,lifecycle-viewmodel-ktx支撑页面间共享状态。没有引入 Navigation Component,是因为在只有底部导航加少量二级页面的场景里,手写切换逻辑更透明,学生也更容易讲清楚每一步在干什么;如果项目后期页面层级变深,再平滑迁移到 Navigation 也不迟。版本号按项目实际编译环境对齐即可,关键是别把一堆用不上的库塞进来,答辩时被问“这个依赖干嘛的”答不上来最尴尬。

3. 总体架构设计:宿主、导航、页面、公共四层怎么咬合

3.1 宿主 Activity 的职责边界

宿主MainActivity是整个框架的“总开关”,它只做四件事:加载根布局、初始化底部导航、托管 Fragment 容器、统一处理状态栏与主题。根布局用CoordinatorLayout或LinearLayout包一个FragmentContainerView加一个BottomNavigationView,容器在上、底栏在下,中间用layout_weight撑开。所有页面切换都通过supportFragmentManager操作容器,宿主本身不持有任何业务数据。

<!-- activity_main.xml 根布局骨架 --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <androidx.fragment.app.FragmentContainerView android:id="@+id/fragment_container" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" /> <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/bottom_nav" android:layout_width="match_parent" android:layout_height="wrap_content" app:menu="@menu/menu_bottom_nav" /> </LinearLayout>

这里有个容易忽略的细节:FragmentContainerView比普通FrameLayout更适合做容器,因为它在配置变更和 Fragment 恢复时行为更稳,能避免“旋转屏幕后 Fragment 重叠”这类玄学问题。底栏放在容器下方而不是用CoordinatorLayout悬浮,是为了让内容区高度计算简单直接,二级页面需要隐藏底栏时再单独处理。宿主布局越简单,后面排查问题的路径就越短。

3.2 底部导航与 Fragment 切换的核心逻辑

底部导航的核心不是“点了能切”,而是“切了状态不丢、返回栈不乱”。我的做法是:用FragmentManager的show/hide而不是replace来切换一级页面,这样每个 Tab 的 Fragment 实例只创建一次,滚动位置、输入内容都能保留;同时给每个 Tab 维护独立的返回栈,二级页面压栈时记录来源 Tab,返回时优先弹当前 Tab 的栈。

// MainActivity 中底部导航切换的核心逻辑 private val fragments = mapOf( R.id.tab_home to HomeFragment(), R.id.tab_message to MessageFragment(), R.id.tab_discover to DiscoverFragment(), R.id.tab_mine to MineFragment() ) private var currentTag: String? = null private fun switchTab(itemId: Int) { val target = fragments[itemId] ?: return val tag = target.javaClass.simpleName val transaction = supportFragmentManager.beginTransaction() // 隐藏当前页面,保留其状态 currentTag?.let { supportFragmentManager.findFragmentByTag(it)?.let { old -> transaction.hide(old) } } // 目标页面已存在则显示,否则新建 val existing = supportFragmentManager.findFragmentByTag(tag) if (existing == null) { transaction.add(R.id.fragment_container, target, tag) } else { transaction.show(existing) } transaction.commit() currentTag = tag }

这段代码的关键点在show/hide与add的配合:首次进入某 Tab 时add创建实例,之后再次切回只show,避免重复创建导致的页面闪烁和状态丢失。currentTag记录当前可见页面,切换前先隐藏它,保证容器里同一时刻只有一个可见 Fragment。参数上,fragments这个 Map 就是“新增模块的落点”——加一个 Tab 只需在菜单里加一项、在 Map 里加一行,框架其余部分不用动,这正是前面说的“低成本扩展”约束的兑现。

3.3 主题与沉浸式状态栏的统一收口

主题和状态栏是最容易各写各的部分。框架里的做法是:在themes.xml里定义一套颜色属性,状态栏、导航栏、底栏选中色全部引用同一组属性;宿主 Activity 在onCreate里统一设置沉浸式,二级页面通过基类 Fragment 复用同一套逻辑,而不是每个页面复制粘贴。

<!-- res/values/themes.xml 关键属性 --> <style name="Theme.AppBase" parent="Theme.Material3.DayNight.NoActionBar"> <item name="colorPrimary">@color/brand_primary</item> <item name="android:statusBarColor">@android:color/transparent</item> <item name="android:windowLightStatusBar">true</item> <item name="android:navigationBarColor">@color/surface</item> </style>

DayNight父主题让深色模式自动跟随系统,windowLightStatusBar控制状态栏图标深浅,透明状态栏配合内容区顶部的fitsSystemWindows或WindowInsets处理,就能做到首页沉浸、详情页实色的切换。这里通常的做法是写一个StatusBarUtil工具类,按页面类型传入“透明”或“实色”两种模式,基类 Fragment 在onViewCreated里调用一次。把这类横切逻辑收口到公共层,是框架和“随手写的 Demo”之间最明显的分界线。

3.4 返回栈与二级页面的处理策略

二级页面的返回行为是验收时最容易被挑刺的地方。框架约定:一级页面之间切换不压栈,二级页面压入当前 Tab 的栈,返回键先弹当前 Tab 的栈,栈空时才交给系统默认行为(退出或回桌面)。实现上可以在宿主里重写onBackPressedDispatcher回调,先询问当前可见 Fragment 是否消费返回事件,未消费再走默认逻辑。

// 宿主中统一拦截返回键 onBackPressedDispatcher.addCallback(this) { val current = supportFragmentManager.findFragmentByTag(currentTag ?: "") val handled = (current as? BackHandler)?.onBackPressed() ?: false if (!handled) { // 当前 Tab 栈为空,交给系统默认行为 isEnabled = false onBackPressedDispatcher.onBackPressed() } }

BackHandler是一个自定义接口,二级页面实现它并自行决定是否消费返回事件,比如详情页返回列表就算消费,列表页再按返回就交还给系统。这种“页面自己管自己的返回”模式,比在宿主里写一堆if-else判断当前是哪个页面要干净得多。参数上,isEnabled = false那一行是防止递归调用的关键,少了它会出现返回键按一次退两层甚至卡死的翻车现场。

4. 关键模块实现:底栏、页面容器与公共基类

4.1 底部导航菜单与选中态定制

BottomNavigationView默认的选中态和项目视觉往往对不上,需要从菜单资源、颜色选择器、图标三处一起改。菜单文件定义 3~5 个 item,每个 item 的icon和title对应一个一级模块;颜色选择器用selector区分选中与未选中;图标建议用矢量图,避免多套分辨率切图。

<!-- res/color/bottom_nav_item_color.xml 选中态颜色 --> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="@color/brand_primary" android:state_checked="true" /> <item android:color="@color/text_secondary" /> </selector>

把这个 selector 通过app:itemIconTint和app:itemTextColor挂到BottomNavigationView上,选中色就跟着主题走了。这里有个血泪经验:如果只改itemTextColor不改itemIconTint,图标颜色不会变,视觉上会显得“只选中了一半”。另外,底栏 item 超过 5 个时 Material 组件会自动启用 shifting 模式,文字可能被隐藏,做毕设时如果模块多,宁可合并入口也别硬塞第六个。

4.2 页面容器与懒加载控制

FragmentContainerView作为容器解决了重叠问题,但“懒加载”还得自己控制。默认show/hide模式下,所有 Tab 首次切换时都会走一遍onCreateView,如果某个 Tab 里有网络请求,用户没点进去就已经在加载了,既浪费流量又可能引发空指针。框架里的做法是在基类 Fragment 里加一个isLoaded标记,配合onHiddenChanged回调,首次可见时才触发数据加载。

// BaseFragment 中的懒加载骨架 abstract class BaseFragment : Fragment() { private var isLoaded = false override fun onResume() { super.onResume() if (!isHidden && !isLoaded) { isLoaded = true onLazyLoad() } } override fun onHiddenChanged(hidden: Boolean) { super.onHiddenChanged(hidden) if (!hidden && !isLoaded) { isLoaded = true onLazyLoad() } } // 子类实现真正的数据加载 protected abstract fun onLazyLoad() }

onResume和onHiddenChanged两处都要判断,是因为首次进入和从其他 Tab 切回走的回调不同,只写一处会漏。isLoaded保证加载只发生一次,避免来回切换时重复请求。这个模式在毕设里特别实用:首页有轮播和列表请求,用户先点“我的”再回首页,不会看到数据重新闪一遍。参数上,onLazyLoad设计成抽象方法,强制每个页面明确自己的加载时机,比在基类里猜业务意图靠谱。

4.3 公共基类与工具方法沉淀

框架能不能被后来者接住,取决于公共层沉淀了多少“不用想就能用”的东西。基类 Fragment 除了懒加载,还应统一提供 ViewBinding 的创建与销毁、状态栏模式设置、Toast 与加载框的封装;工具层放StatusBarUtil、DensityUtil、KeyboardUtil这类高频小工具。原则是:凡是两个以上页面会重复写的代码,就往上提一层。

// BaseFragment 中 ViewBinding 的统一处理 abstract class BaseFragment<VB : ViewBinding> : Fragment() { private var _binding: VB? = null protected val binding: VB get() = _binding!! abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = inflateBinding(inflater, container) return binding.root } override fun onDestroyView() { super.onDestroyView() _binding = null // 必须置空,否则内存泄漏 } }

_binding置空这一步是硬性要求,Fragment 的 View 生命周期短于 Fragment 本身,不置空就会持有已销毁的视图,内存泄漏查起来非常隐蔽。泛型VB让每个子类绑定自己的布局类型,编译期就能发现类型错误,比findViewById安全得多。这套基类写好后,新增页面只需继承并实现inflateBinding和onLazyLoad两个方法,其余全部继承,扩展成本被压到最低。

4.4 深色模式与多分辨率适配

深色模式不是加一个values-night就完事,底栏选中色、状态栏图标、页面背景都要成对定义。框架约定:所有颜色走语义化命名(surface、text_primary、brand_primary),在values和values-night各放一份,页面里只引用语义名,不写死色值。多分辨率适配则靠dp加矢量图加ConstraintLayout的百分比约束,避免在平板上布局被拉变形。

<!-- res/values-night/colors.xml 深色模式对应色 --> <resources> <color name="surface">#121212</color> <color name="text_primary">#E6E6E6</color> <color name="text_secondary">#9E9E9E</color> <color name="brand_primary">#8AB4F8</color> </resources>

语义化命名的好处是切换主题时不用改页面代码,深色模式下brand_primary换成更亮的蓝,底栏和按钮自动跟着变。这里踩过的坑是:状态栏图标颜色在深色模式下要反过来,windowLightStatusBar得设成false,否则深色背景配深色图标,状态栏直接“消失”。这类细节单看很小,但答辩演示时一开深色模式就露馅,提前在框架层处理掉最省心。

5. 避坑与排查:这套框架最容易翻车的五个点

5.1 旋转屏幕后 Fragment 重叠

现象是横竖屏切换或系统回收后重建,页面上出现两层内容叠在一起。原因通常是宿主在onCreate里无条件addFragment,而系统已经帮你恢复了之前的实例,于是新旧两份同时存在。解决办法是在add前判断savedInstanceState == null,非空时交给系统恢复,宿主只负责把currentTag同步回来。这个坑在模拟器上不一定复现,真机上切几次后台就出来了。

5.2 底栏切换后页面状态丢失

现象是切走再切回,列表滚动位置回到顶部、输入框内容清空。原因多半是用了replace而不是show/hide,每次切换都重建 Fragment。解决是把切换逻辑统一改成add + show/hide,并确保fragmentsMap 里的实例是复用的而不是每次 new。改完后要专门测一遍“输入内容→切 Tab→切回”的路径,确认状态还在。

5.3 返回键一次退两层

现象是按一次返回直接退出应用或跳过一级页面。原因通常是宿主和 Fragment 都注册了返回回调,事件被消费了两次,或者isEnabled没及时置位导致递归。解决是只保留宿主一处OnBackPressedCallback,由它统一询问当前页面是否消费,页面内部不再单独注册;同时确认消费后不再调用onBackPressedDispatcher.onBackPressed()。

5.4 深色模式下状态栏图标看不见

现象是切到深色模式后状态栏时间、电量图标和背景同色。原因是windowLightStatusBar固定为true,深色背景配深色图标。解决是在主题里按values-night分别设置该属性,或在StatusBarUtil里根据当前模式动态切换。验收时务必在系统深色模式下走一遍所有一级页面,别只在浅色下看。

5.5 ViewBinding 未置空导致内存泄漏

现象是反复进出页面后内存持续上涨,LeakCanary 报 Fragment 持有 View。原因是_binding在onDestroyView后没置空。解决是在基类里统一置空,子类不允许自己持有 binding 的强引用。这个坑隐蔽性强,建议在框架阶段就用基类堵死,别指望每个开发者都记得手动清理。

6. 验收与答辩:怎么证明这套框架真的能用

验收这套 Android 整体 UI 框架,我一般不看页面多花哨,只看五条可复现的检查项。第一条,新增一个一级模块:从改菜单、加 Fragment、注册 Map 三步走,计时看是否在十分钟内完成且不影响其他 Tab。第二条,状态保持:在首页列表滚动到中间、在“我的”页输入一段文字,来回切换三次,确认位置和内容都还在。第三条,返回栈:从首页进二级页再返回,确认回到首页而非退出;连续按返回,确认逐层弹出后正常退出。第四条,主题切换:系统切深色模式,逐个 Tab 检查底栏选中色、状态栏图标、页面背景是否成对变化。第五条,旋转与后台回收:横竖屏切换、切后台再回来,确认无重叠、无崩溃。

答辩时最容易被问的是“为什么这么分层”和“新增模块要改哪些文件”。前者用第 3 章的职责边界回答,后者当场演示一遍新增流程最有说服力。我建议提前准备一张模块职责表,把宿主、导航、页面、公共四层各自负责什么、不负责什么写清楚,被追问时直接对着讲,比临场组织语言稳得多。

验收项操作路径通过标准
模块扩展新增一个 Tab 并注册十分钟内完成,其余 Tab 不受影响
状态保持列表滚动/输入后切 Tab 再切回位置与内容均保留
返回栈一级进二级再连续返回逐层弹出,栈空后正常退出
主题切换系统深色模式逐页检查颜色成对变化,图标可见
生命周期旋转屏幕、切后台再回无重叠、无崩溃、无泄漏

还有一个小技巧:把上面这张表做成一个手动测试清单,每改一次框架就跑一遍,比事后靠记忆补测靠谱。我吃过一次亏,底栏切换逻辑改完后只测了首页,结果“我的”页因为没实现懒加载接口直接崩了,答辩前半小时才发现。从那以后我每次动框架层代码,都强制把这张清单从头走一遍,哪怕只改了一行。希望这套结构和清单能帮到你,拿去改改就能变成你自己的项目底座。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进