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 配置加载的优先级
配置来源多了,就得有个优先级。我一般这样排:
- 编译期默认值:最低优先级,兜底用
- 本地持久化配置:用户上次保存的
- 服务端动态配置:覆盖本地配置
- 用户手动设置:最高优先级,用户说了算
代码实现上,我习惯用“链式覆盖”的方式:
// 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的核心逻辑串起来了,我画的时候特意把“配置下发”和“本地持久化”放在同一层,因为它们是一对“搭档”:
15.7 避坑指南
做配置管理这些年,我踩过不少坑。挑几个典型的说说:
- 配置膨胀:FeatureFlag越来越多,最后没人知道哪些在用。我后来加了“未使用Flag检测”,定期清理。
- 并发写入:多个线程同时写SharedPreferences,数据会乱。我统一用单线程的Executor处理写操作。
- 配置回滚:服务端下发了错误配置,Launcher直接崩了。我加了“安全模式”:如果新配置导致崩溃,自动回滚到上一版。
- 版本兼容:老版本Launcher不认识新配置字段。我解析时用@SerializedName加版本号,不认识的字段直接跳过。
我曾经遇到过一个问题:动态配置下发后,UI没有及时刷新。排查了半天,发现是配置更新后忘了通知观察者。所以我现在每次配置变更,都会通过LiveData或Flow通知UI层。
嗯,LauncherConfig这块其实不难,但细节很多。把FeatureFlag、动态配置、本地持久化这三块理清楚,你的Launcher就能“活”起来。下次产品经理说“这个功能灰度一下”,你就能淡定地说:“改个配置就行。”
公众号:蓝海资料掘金营,微信deep3321