15、Launcher的配置(LauncherConfig):FeatureFlag的开关控制、动态配置下发、配置的本地持久化

做Launcher开发这些年,我越来越觉得配置管理是门学问。说白了,Launcher就是个“大管家”,桌面长啥样、图标能不能拖、手势怎么响应,全得靠配置说了算。今天咱们就聊聊LauncherConfig这套东西——FeatureFlag怎么玩、动态配置怎么下、本地怎么存。

15.1 为什么需要LauncherConfig?

你想想看,一个Launcher要适配不同厂商、不同机型、不同Android版本。有的手机支持三指截屏,有的不支持;有的桌面图标可以自由摆放,有的只能网格对齐。这些差异怎么处理?

我早期做的一个项目,所有功能开关都硬编码在代码里。结果产品经理说“这个功能灰度一下”,我整个人都麻了。改代码、发版本、等OTA,一套流程走下来黄花菜都凉了。

所以后来我设计LauncherConfig时,核心思路就三个:

  • FeatureFlag:功能开关,决定某个特性是否启用
  • 动态配置下发:从服务端拉配置,不用发版就能改
  • 本地持久化:配置存下来,下次启动直接读

核心原则:配置即代码,但配置比代码更灵活。能走配置的,绝不写死。

15.2 FeatureFlag:功能的“总闸”

FeatureFlag说白了就是个布尔开关。但实际项目中,我习惯把它设计成三层结构:

层级 作用 示例
编译期Flag 通过BuildConfig控制,编译后不可变 DEBUG模式、厂商定制
运行时Flag 内存中的开关,重启后丢失 用户临时设置、A/B测试
持久化Flag 存到SharedPreferences或数据库 用户偏好、服务端下发的配置

我一般这样定义FeatureFlag:

// FeatureFlag.kt
enum class FeatureFlag(
    val key: String,
    val defaultValue: Boolean,
    val persistenceLevel: PersistenceLevel
) {
    // 编译期:通过BuildConfig控制
    ENABLE_GRID_LAYOUT("grid_layout", BuildConfig.ENABLE_GRID, PersistenceLevel.COMPILE),
    
    // 运行时:内存中,重启后恢复默认
    ENABLE_DYNAMIC_ICON("dynamic_icon", false, PersistenceLevel.RUNTIME),
    
    // 持久化:存到本地
    ENABLE_GESTURE_NAV("gesture_nav", true, PersistenceLevel.PERSISTENT),
    ENABLE_WIDGET_PREVIEW("widget_preview", false, PersistenceLevel.PERSISTENT)
}

enum class PersistenceLevel {
    COMPILE,    // 编译期
    RUNTIME,    // 运行时
    PERSISTENT  // 持久化
}

我的习惯:每个FeatureFlag都配上注释,说明这个开关控制什么、默认值为什么这么设。不然三个月后你自己都忘了这个Flag是干嘛的。

15.3 动态配置下发:让Launcher“活”起来

动态配置下发,说白了就是让Launcher能从服务端拉配置。我经历过一个血泪教训:某次灰度测试,需要关闭某个功能,结果发现配置下发链路没打通,只能紧急发版。从那以后,我把动态配置当成Launcher的“生命线”来设计。

整体流程是这样的:

// 配置下发流程
1. Launcher启动 → 检查本地缓存配置
2. 异步请求服务端 → 获取最新配置JSON
3. 解析配置 → 更新FeatureFlag
4. 持久化到本地 → 下次启动直接读
5. 通知UI刷新 → 应用新配置

我常用的配置JSON格式:

{
  "version": 2,
  "features": {
    "gesture_nav": true,
    "dynamic_icon": false,
    "widget_preview": true
  },
  "params": {
    "grid_rows": 5,
    "grid_cols": 4,
    "icon_size": 48,
    "animation_duration": 300
  },
  "experiments": {
    "new_dock_style": {
      "enabled": true,
      "rollout": 0.5
    }
  }
}

解析时我习惯用Gson直接映射到数据类:

// RemoteConfig.kt
data class RemoteConfig(
    val version: Int,
    val features: Map<String, Boolean>,
    val params: Map<String, Any>,
    val experiments: Map<String, ExperimentConfig>
)

data class ExperimentConfig(
    val enabled: Boolean,
    val rollout: Float  // 0.0 ~ 1.0
)

注意:动态配置下发一定要做版本管理。我见过一个坑:服务端下发了新配置,但Launcher版本太老不认识,直接解析崩溃。所以解析时一定要加版本兼容判断,不认识的字段直接忽略。

15.4 配置的本地持久化

配置拉下来不能每次都用网络吧?本地持久化是必须的。我一般用SharedPreferences存简单配置,用Room数据库存复杂配置。

先看SharedPreferences的方式:

// ConfigStorage.kt
class ConfigStorage(context: Context) {
    private val prefs = context.getSharedPreferences("launcher_config", Context.MODE_PRIVATE)
    
    fun getBoolean(key: String, default: Boolean): Boolean {
        return prefs.getBoolean(key, default)
    }
    
    fun setBoolean(key: String, value: Boolean) {
        prefs.edit().putBoolean(key, value).apply()
    }
    
    fun getString(key: String, default: String): String {
        return prefs.getString(key, default) ?: default
    }
    
    fun setString(key: String, value: String) {
        prefs.edit().putString(key, value).apply()
    }
}

如果配置比较复杂,比如有嵌套结构、需要查询,我建议用Room:

// ConfigEntity.kt
@Entity(tableName = "config")
data class ConfigEntity(
    @PrimaryKey val key: String,
    val value: String,  // JSON字符串
    val updatedAt: Long
)

// ConfigDao.kt
@Dao
interface ConfigDao {
    @Query("SELECT * FROM config WHERE `key` = :key")
    suspend fun getConfig(key: String): ConfigEntity?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun saveConfig(config: ConfigEntity)
}

我的建议:不要把所有配置都存成同一个类型。布尔开关用SharedPreferences,复杂配置用Room。混着用反而更清晰。

15.5 配置加载的优先级

配置来源多了,就得有个优先级。我一般这样排:

  1. 编译期默认值:最低优先级,兜底用
  2. 本地持久化配置:用户上次保存的
  3. 服务端动态配置:覆盖本地配置
  4. 用户手动设置:最高优先级,用户说了算

代码实现上,我习惯用“链式覆盖”的方式:

// ConfigManager.kt
class ConfigManager(
    private val localStorage: ConfigStorage,
    private val remoteConfig: RemoteConfigProvider
) {
    fun getBoolean(flag: FeatureFlag): Boolean {
        // 1. 用户手动设置
        if (flag.persistenceLevel == PersistenceLevel.PERSISTENT) {
            val userValue = localStorage.getBoolean(flag.key, null)
            if (userValue != null) return userValue
        }
        
        // 2. 服务端动态配置
        val remoteValue = remoteConfig.getBoolean(flag.key)
        if (remoteValue != null) return remoteValue
        
        // 3. 本地持久化
        if (flag.persistenceLevel == PersistenceLevel.PERSISTENT) {
            val localValue = localStorage.getBoolean(flag.key, null)
            if (localValue != null) return localValue
        }
        
        // 4. 编译期默认值
        return flag.defaultValue
    }
}

15.6 知识体系总览

下面这张图把LauncherConfig的核心逻辑串起来了,我画的时候特意把“配置下发”和“本地持久化”放在同一层,因为它们是一对“搭档”:

LauncherConfig 配置体系 编译期默认值 服务端动态配置 用户手动设置 ConfigManager 链式覆盖 · 优先级合并 编译期Flag 运行时Flag 持久化Flag SharedPreferences / Room

15.7 避坑指南

做配置管理这些年,我踩过不少坑。挑几个典型的说说:

  • 配置膨胀:FeatureFlag越来越多,最后没人知道哪些在用。我后来加了“未使用Flag检测”,定期清理。
  • 并发写入:多个线程同时写SharedPreferences,数据会乱。我统一用单线程的Executor处理写操作。
  • 配置回滚:服务端下发了错误配置,Launcher直接崩了。我加了“安全模式”:如果新配置导致崩溃,自动回滚到上一版。
  • 版本兼容:老版本Launcher不认识新配置字段。我解析时用@SerializedName加版本号,不认识的字段直接跳过。

我曾经遇到过一个问题:动态配置下发后,UI没有及时刷新。排查了半天,发现是配置更新后忘了通知观察者。所以我现在每次配置变更,都会通过LiveData或Flow通知UI层。

嗯,LauncherConfig这块其实不难,但细节很多。把FeatureFlag、动态配置、本地持久化这三块理清楚,你的Launcher就能“活”起来。下次产品经理说“这个功能灰度一下”,你就能淡定地说:“改个配置就行。”


公众号:蓝海资料掘金营,微信deep3321