简介:这份资源面向Android应用开发者,尤其是希望系统掌握UI框架搭建的中级学习者,围绕底部导航栏定制、Fragment灵活切换与项目整体架构设计三个核心问题展开,帮助解决界面风格不统一、页面切换生硬、代码耦合度高等常见痛点。压缩包共214个文件,约5.25MB,以128个png界面素材、24个xml布局与配置、18个java源码及30个class文件为主体,另含jar依赖、apk成品与dex等,覆盖从资源到可运行工程的完整链路。目前已有502人学习下载,可作为参考范例。读者可从中获得一套可直接对照的UI骨架:自定义TabBar的样式与交互实现、基于FragmentManager的切换与过渡动画处理,以及MVP或MVVM分层下的代码组织方式,便于快速搭建结构清晰、易维护的Android项目界面框架。
1. 一套能直接跑起来的安卓底部导航 UI 框架,到底解决了什么问题
很多安卓项目死在“第一屏”上:底部导航栏切来切去,Fragment 状态丢了、返回栈乱了、状态栏颜色跟页面打架、深色模式一切换整个界面花掉。这套 android 项目整体 UI 框架,核心就是把底部导航(BottomNavigationView + Fragment 容器)这条主干链路做成一整套可复用的骨架,配套主题、状态栏适配、页面切换动画和返回栈管理。它适合两类人:一是刚接手一个空壳工程、需要快速搭出多页签主界面的开发者;二是手里有老项目、底部导航逻辑散落在 Activity 里、想抽成统一框架的中高级工程师。安卓源码看得再多,不如把这一套骨架拆开跑一遍,坑基本都在这几十个文件里。
2. 框架的骨架拆解:底部导航、Fragment 容器与状态保存怎么串起来
2.1 为什么用单 Activity + 多 Fragment,而不是多 Activity 堆叠
底部导航类应用的经典结构是“一个宿主 Activity + N 个一级 Fragment”。多 Activity 方案在切页签时会有明显的转场动画割裂感,而且每个 Activity 都要重复写状态栏、主题、返回逻辑,维护成本随页签数量线性上涨。单 Activity 方案把导航栏固定在宿主里,Fragment 只负责内容区,切换时用FragmentTransaction的show/hide或replace控制,视觉上更接近主流 App 的体验。
这套框架选的是单 Activity 路线,宿主里放一个FragmentContainerView作为内容容器,底部挂BottomNavigationView。关键点在于:导航栏的选中状态和 Fragment 的显示状态必须由同一个数据源驱动,否则就会出现“导航栏高亮在第二个、内容还停在第一个”的经典翻车。框架里用一个currentTabIndex字段加OnItemSelectedListener做单一入口,所有切换都走同一个方法,避免多处 setSelected 打架。
选型上还有一个容易忽略的点:FragmentContainerView比直接用FrameLayout更稳,它在onCreateView阶段就会把 Fragment 的视图挂进去,配合FragmentTransaction的setReorderingAllowed(true)能减少状态恢复时的崩溃。老项目里如果还是FrameLayout,迁移时注意把android:name属性去掉,改用代码 add,否则会出现重复添加。
2.2 宿主 Activity 的初始化顺序:先容器、再导航、最后恢复状态
初始化顺序错了,状态恢复就会出问题。框架里的宿主 Activity 按固定顺序走:先setContentView拿到容器和导航栏引用,再初始化 Fragment 列表,最后根据savedInstanceState决定是恢复还是默认选中第一个。下面这段是核心初始化逻辑,可以直接对照自己的工程改:
class MainHostActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private var currentTabIndex = 0 // 一级页面列表,顺序必须与底部导航菜单顺序一致 private val fragments = listOf( HomeFragment(), DiscoverFragment(), MessageFragment(), ProfileFragment() ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // 1. 先恢复索引,再挂 Fragment,避免恢复时重复 add currentTabIndex = savedInstanceState?.getInt(KEY_TAB_INDEX) ?: 0 // 2. 只在首次创建时 add,重建时 FragmentManager 会自动恢复 if (savedInstanceState == null) { supportFragmentManager.beginTransaction() .setReorderingAllowed(true) .apply { fragments.forEachIndexed { index, fragment -> add(R.id.fragment_container, fragment, "tab_$index") if (index != currentTabIndex) hide(fragment) } } .commit() } // 3. 导航栏选中态与内容态对齐 binding.bottomNav.selectedItemId = menuIdOf(currentTabIndex) binding.bottomNav.setOnItemSelectedListener { item -> switchTab(menuIdToIndex(item.itemId)) true } } private fun switchTab(index: Int) { if (index == currentTabIndex) return val target = supportFragmentManager.findFragmentByTag("tab_$index") ?: return val current = supportFragmentManager.findFragmentByTag("tab_$currentTabIndex") supportFragmentManager.beginTransaction() .setReorderingAllowed(true) .apply { current?.let { hide(it) } if (target.isAdded) show(target) else add(R.id.fragment_container, target, "tab_$index") } .commit() currentTabIndex = index } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putInt(KEY_TAB_INDEX, currentTabIndex) } companion object { private const val KEY_TAB_INDEX = "key_tab_index" } }这段代码有三个参数值得盯:setReorderingAllowed(true)让 Fragment 的 add/hide 在同一事务里按顺序执行,减少中间态闪烁;savedInstanceState == null这个判断是防止旋转屏幕后 Fragment 被重复添加的关键,很多“页面叠了两层”的 bug 就出在这里;currentTabIndex存进 Bundle 是为了让导航栏高亮和内容在重建后仍然一致。menuIdOf和menuIdToIndex是两个映射函数,菜单项 id 和索引的对应关系建议写死成常量表,别用menu.getItem(i)动态取,菜单顺序一变就错位。
2.3 底部导航菜单与主题属性的绑定
BottomNavigationView的图标和文字颜色默认跟随主题的colorPrimary,但很多设计稿要求“选中态用主色、未选中态用灰色”。框架里用ColorStateList放在res/color/bottom_nav_item.xml,再通过app:itemIconTint和app:itemTextColor引用。这样切换深色模式时只需要换一套 color 资源,不用改代码。
菜单文件res/menu/menu_bottom_nav.xml里每个item的id必须和代码里的映射表一致,orderInCategory决定显示顺序。一个常见坑是菜单项超过 5 个时,BottomNavigationView会自动把多余项收进“更多”,如果产品要求全部平铺,需要设置app:labelVisibilityMode="labeled"并控制数量在 5 以内,超过就得换NavigationBarView的自定义方案。
3. 状态栏、深色模式与页面切换动画:UI 框架的适配层怎么写
3.1 状态栏沉浸式适配:别在每个 Fragment 里重复写
状态栏适配是底部导航框架里最容易写散的部分。常见做法是在每个 Fragment 的onResume里改状态栏颜色,结果切页签时颜色闪一下,返回时又不对。框架把这层收口到宿主 Activity,提供一个applyStatusBarStyle(style)方法,Fragment 只声明自己需要什么风格,由宿主统一执行。
enum class StatusBarStyle { LIGHT_CONTENT, DARK_CONTENT, TRANSPARENT } fun applyStatusBarStyle(style: StatusBarStyle) { val controller = WindowCompat.getInsetsController(window, window.decorView) when (style) { StatusBarStyle.LIGHT_CONTENT -> { // 浅色背景,图标用深色 controller.isAppearanceLightStatusBars = true window.statusBarColor = ContextCompat.getColor(this, R.color.bg_primary) } StatusBarStyle.DARK_CONTENT -> { controller.isAppearanceLightStatusBars = false window.statusBarColor = ContextCompat.getColor(this, R.color.bg_dark) } StatusBarStyle.TRANSPARENT -> { window.statusBarColor = Color.TRANSPARENT WindowCompat.setDecorFitsSystemWindows(window, false) } } }isAppearanceLightStatusBars控制的是图标颜色,不是背景色,这两个概念经常被混。setDecorFitsSystemWindows(false)一旦打开,内容会延伸到状态栏下面,这时候 Fragment 的根布局必须加android:fitsSystemWindows="true"或者手动处理 inset,否则标题会被状态栏盖住。框架里统一在宿主根布局处理 inset,Fragment 不再各自处理,这是减少适配 bug 的核心原则。
3.2 深色模式切换:资源限定符 + 动态刷新两步走
深色模式不是简单加一个values-night就完事。底部导航的选中态颜色、Fragment 里的图片、状态栏图标都要跟着变。框架的做法是:颜色和 drawable 全部走资源限定符,代码里不写死色值;切换时通过AppCompatDelegate.setDefaultNightMode()触发重建,宿主在onCreate里重新读取当前模式并调用applyStatusBarStyle。
需要动态刷新、不想重建 Activity 的场景(比如设置页里切换),可以用recreate()配合onSaveInstanceState恢复当前页签索引,效果上用户感知不到跳页。注意recreate()之后savedInstanceState不为空,前面 2.2 里的savedInstanceState == null判断会跳过 Fragment 的重复 add,这正是它存在的意义。
3.3 页面切换动画:用 ViewPager2 还是手动事务
如果产品要求左右滑动切页签,ViewPager2+BottomNavigationView是更省事的组合,ViewPager2内部用FragmentStateAdapter管理 Fragment,滑动和点击导航栏通过setCurrentItem双向同步。但ViewPager2默认会预加载相邻页,内存敏感的场景要设置offscreenPageLimit = 1。
如果只要求点击切换、不要滑动,手动FragmentTransaction更轻,还能自定义淡入淡出动画。框架里两种都留了入口,通过一个enableSwipe开关切换。手动事务加动画的写法:
supportFragmentManager.beginTransaction() .setCustomAnimations( R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out ) .hide(current) .show(target) .commit()setCustomAnimations的四个参数依次是进入、退出、返回时进入、返回时退出,只传两个的话返回动画会复用前两个,容易出现“返回时动画方向反了”的观感问题。动画时长建议控制在 200ms 以内,超过 300ms 用户会觉得卡。
4. 避坑与排查:底部导航框架里最容易翻车的五个地方
4.1 旋转屏幕后页面叠了两层
现象:横竖屏切换后,内容区出现两个 Fragment 的视图重叠,点击事件错乱。原因:onCreate里无条件执行了add,而FragmentManager在重建时已经自动恢复了之前的 Fragment 实例,导致同一 tag 被添加两次。解决:所有首次添加逻辑包在savedInstanceState == null判断里,重建路径只做导航栏选中态对齐,不再 add。
4.2 快速连点导航栏导致事务冲突
现象:连续快速点击不同页签,偶发IllegalStateException: Fragment already added或页面白屏。原因:多个commit()在同一个消息循环里排队,前一个事务还没执行完,后一个又基于旧状态做判断。解决:改用commitNow()或在switchTab入口加一个isSwitching标志位做节流;更稳的做法是把切换逻辑收敛到supportFragmentManager.executePendingTransactions()之后再做状态判断。
4.3 状态栏图标在深色页面看不清
现象:进入深色背景的 Fragment 后,状态栏图标还是深色,几乎看不见。原因:只在宿主onCreate里设置了一次isAppearanceLightStatusBars,Fragment 切换时没有重新应用。解决:在switchTab成功后根据目标 Fragment 声明的StatusBarStyle调用applyStatusBarStyle,把状态栏风格作为 Fragment 的一个属性暴露出来。
4.4 返回键直接退出应用而不是回到首页
现象:在第二个页签按返回键,应用直接退出,用户期望回到第一个页签。原因:宿主没有处理OnBackPressedCallback,系统默认行为是 finish。解决:注册OnBackPressedDispatcher回调,当前索引不是 0 时切回 0 并消费事件,是 0 时才放行给系统。注意用addCallback(this, callback)绑定生命周期,避免内存泄漏。
4.5 深色模式切换后底部导航选中色没变
现象:切到深色模式,页面背景变了,但底部导航选中项还是浅色主题的蓝色。原因:itemIconTint引用的是一个写死色值的ColorStateList,没有走values-night限定符。解决:把res/color/bottom_nav_item.xml里的色值改成引用@color/nav_selected,并在values-night/colors.xml里给同名颜色换一套值,系统会自动按当前模式解析。
5. 进阶:把 UI 框架做成可配置的导航描述表
框架跑通之后,真正省事的一步是把“页签数量、标题、图标、Fragment 类、状态栏风格”抽成一张配置表,宿主只负责读表渲染。这样新增一个页签不用改宿主代码,产品改需求时改动面从三四个文件缩到一个数据类。下面是一个最小可用的描述结构:
data class TabSpec( val menuId: Int, val titleRes: Int, val iconRes: Int, val fragmentClass: Class<out Fragment>, val statusBarStyle: StatusBarStyle ) object TabConfig { val tabs = listOf( TabSpec(R.id.tab_home, R.string.tab_home, R.drawable.ic_home, HomeFragment::class.java, StatusBarStyle.LIGHT_CONTENT), TabSpec(R.id.tab_discover, R.string.tab_discover, R.drawable.ic_discover, DiscoverFragment::class.java, StatusBarStyle.LIGHT_CONTENT), TabSpec(R.id.tab_message, R.string.tab_message, R.drawable.ic_message, MessageFragment::class.java, StatusBarStyle.DARK_CONTENT), TabSpec(R.id.tab_profile, R.string.tab_profile, R.drawable.ic_profile, ProfileFragment::class.java, StatusBarStyle.LIGHT_CONTENT) ) }宿主初始化时遍历TabConfig.tabs动态构建菜单和 Fragment 列表,menuId与索引的映射也由这张表生成,彻底消除手写映射表错位的可能。fragmentClass用反射实例化时记得处理newInstance异常,或者干脆改成传工厂 lambda,避免混淆规则把类名改掉导致反射失败。
验证这套框架是否真的稳,我一般会走一遍固定清单:旋转屏幕三次看是否叠层、快速连点五个页签看是否崩溃、深色模式来回切两次看颜色和状态栏、在非首页按返回键看是否回首页、从后台恢复看页签是否停在离开时的位置。这五步走完基本能覆盖 90% 的导航类问题。
从那以后我每次接新工程,都会先把这套导航骨架和验证清单跑一遍再动业务代码,省下来的排查时间远比搭骨架的时间多。希望帮到你。
本文还有配套的精品资源,点击获取