4、Launcher主界面架构:Activity与Fragment设计、主界面布局(状态栏、导航栏、内容区)、屏幕适配与分辨率处理

好,咱们进入第四章。这一章要聊的是Launcher的骨架——主界面架构。

说实话,车载Launcher和手机Launcher最大的区别在哪?手机你可以随便折腾,大不了重启。车载不行。用户正在开车,你界面卡了、布局乱了、导航栏点不动了,那是要出事的。所以这一章的内容,我建议你认真看。

4.1 Activity与Fragment设计:谁主谁次?

很多新手会问:Launcher主界面到底用Activity还是Fragment?

我的答案是:一个Activity + 多个Fragment。这是车载行业的主流做法,也是我踩过坑之后的选择。

为什么不用多Activity?

车载系统对内存和启动速度要求极高。每多一个Activity,就意味着多一份系统资源开销。而且Activity之间的切换会有明显的闪白或黑屏,这在驾驶过程中非常影响体验。

我当年第一个车载项目,就是用了三个Activity分别做主页、应用列表和设置。结果呢?客户试驾时一划屏,中间闪了一下白屏,当场被骂回来。后来全改成Fragment,问题解决。

4.1.1 主Activity的职责

主Activity其实很轻。它只做三件事:

  • 初始化系统窗口(比如全屏、隐藏导航栏)
  • 管理Fragment的切换
  • 处理系统级事件(如熄屏、唤醒)

说白了,它就是个容器。真正的UI内容都在Fragment里。

class LauncherActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 设置全屏,隐藏系统状态栏
        window.setFlags(
            WindowManager.LayoutParams.FLAG_FULLSCREEN,
            WindowManager.LayoutParams.FLAG_FULLSCREEN
        )
        setContentView(R.layout.activity_launcher)
        
        // 加载主Fragment
        if (savedInstanceState == null) {
            supportFragmentManager.beginTransaction()
                .replace(R.id.fragment_container, MainFragment())
                .commit()
        }
    }
}

4.1.2 Fragment的划分策略

我个人习惯把Launcher拆成三个核心Fragment:

Fragment名称 职责 生命周期
StatusBarFragment 显示时间、信号、蓝牙连接状态 常驻,不销毁
ContentFragment 主内容区,比如地图、音乐卡片 可替换,支持懒加载
NavigationBarFragment 底部快捷按钮,返回主页、空调等 常驻,不销毁

你想想看,状态栏和导航栏几乎不会变,如果每次切换页面都重新创建,那得多浪费性能?所以这两个Fragment我设置为setRetainInstance(true),让它们一直活着。

小技巧:Fragment的切换建议用show/hide而不是replace。replace会销毁重建,show/hide只是控制可见性。我在项目中测试过,show/hide的切换速度比replace快30%以上。

4.2 主界面布局:三块区域怎么分?

车载Launcher的布局,说白了就是三个字:上、中、下

上边是状态栏,显示时间、网络、蓝牙这些信息。中间是内容区,放地图、音乐、天气这些卡片。下边是导航栏,放返回主页、空调控制、应用列表这些快捷入口。

嗯,这里要注意:千万不要用绝对布局。车载屏幕千奇百怪,有方的、有长的、有带鱼屏,绝对布局会让你改到怀疑人生。

4.2.1 布局文件示例

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <!-- 状态栏区域,高度固定为48dp -->
    <FrameLayout
        android:id="@+id/status_bar_container"
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:background="#1A000000" />

    <!-- 内容区域,权重为1,自动填充剩余空间 -->
    <FrameLayout
        android:id="@+id/content_container"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

    <!-- 导航栏区域,高度固定为56dp -->
    <FrameLayout
        android:id="@+id/nav_bar_container"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:background="#FF1A1A1A" />

</LinearLayout>

为什么状态栏和导航栏用固定高度?因为车载屏幕虽然分辨率不同,但物理尺寸差异不大。48dp和56dp在大多数屏幕上看起来都合适。内容区用layout_weight="1",让它自适应填充。

注意:有些车机系统会自带虚拟按键(比如返回键、Home键)。如果你的导航栏和系统虚拟按键重叠了,用户会点错。我曾经在一个项目里没处理这个问题,结果用户想点空调,却按到了系统返回键,直接被投诉。解决方案是在布局底部加上android:fitsSystemWindows="true",或者动态获取系统导航栏高度并做偏移。

4.3 屏幕适配与分辨率处理

这是车载开发里最让人头疼的问题,没有之一。

手机屏幕好歹还有个主流分辨率,车载屏幕呢?1024x600、1280x720、1920x1080、1920x720(带鱼屏)、甚至还有3840x720的超宽屏。你想想看,同一个布局在不同屏幕上可能完全走样。

4.3.1 使用dp和sp,别用px

这个道理大家都懂,但我还是要强调:所有尺寸都用dp,所有字体都用sp。px是绝对像素,在不同密度屏幕上会变形。dp是密度无关像素,系统会自动换算。

举个例子:

  • 在160dpi的屏幕上,1dp = 1px
  • 在320dpi的屏幕上,1dp = 2px
  • 在480dpi的屏幕上,1dp = 3px

所以如果你用px写了一个48px的按钮,在低分辨率屏幕上可能很大,在高分辨率屏幕上可能小到点不到。用dp就没这个问题。

4.3.2 创建多套资源文件夹

我建议至少创建以下资源文件夹:

res/
  layout-sw600dp/   // 7英寸以上平板类屏幕
  layout-sw720dp/   // 10英寸以上大屏
  layout-land/      // 横屏专用布局
  values-sw600dp/   // 对应的尺寸和字体资源
  values-sw720dp/
  drawable-mdpi/    // 低分辨率图标
  drawable-hdpi/    // 中分辨率图标
  drawable-xhdpi/   // 高分辨率图标
  drawable-xxhdpi/  // 超高分辨率图标

为什么要分这么细?因为车载屏幕的宽高比差异太大了。同样是1920宽,有的屏幕高1080,有的高720。你用同一套布局,在1920x1080上可能刚好,在1920x720上内容区就被压扁了。

我的经验:对于超宽屏(比如1920x720),我通常会单独写一套布局。内容区的卡片从横向排列改成网格排列,避免单行太长导致用户视线需要左右移动。这在驾驶过程中非常重要——用户扫一眼就要获取信息,视线移动越少越好。

4.3.3 动态适配代码

有时候光靠资源文件夹不够,还需要在代码里动态适配。比如获取屏幕宽高,然后按比例计算元素大小。

fun getScreenWidthHeight(context: Context): Pair<Int, Int> {
    val displayMetrics = context.resources.displayMetrics
    return Pair(displayMetrics.widthPixels, displayMetrics.heightPixels)
}

// 按比例计算卡片宽度,比如占屏幕宽度的45%
fun calculateCardWidth(screenWidth: Int): Int {
    return (screenWidth * 0.45).toInt()
}

但要注意,不要滥用动态计算。能写在xml里的尽量写在xml里,代码里只处理那些确实需要运行时判断的情况。比如某些车机在行驶中会隐藏状态栏,这时候内容区的高度就需要重新计算。

4.3.4 适配测试清单

我每次做完适配,都会拿这个清单过一遍:

  1. 所有文字是否完整显示,没有截断?
  2. 按钮点击区域是否不小于48dp(人机工程学最小尺寸)?
  3. 横竖屏切换后布局是否正常?
  4. 系统虚拟按键弹出后,内容区是否被遮挡?
  5. 不同dpi下图标是否清晰,没有模糊?

嗯,这个清单看起来简单,但每一条我都踩过坑。尤其是第4条,系统虚拟按键的高度在不同车机上不一样,有的24dp,有的48dp。我建议动态获取:

fun getNavigationBarHeight(context: Context): Int {
    val resourceId = context.resources
        .getIdentifier("navigation_bar_height", "dimen", "android")
    return if (resourceId > 0) {
        context.resources.getDimensionPixelSize(resourceId)
    } else {
        0
    }
}

好了,这一章的内容就这些。核心就三句话:一个Activity管全局,三个Fragment分区域,dp+多资源文件夹搞定适配。下一章咱们开始真正写代码,把状态栏和导航栏做出来。