深入 Android 换肤框架全链路:从 Resources 动态替换到 Compose 声明式主题的运行时切换架构
接手过一个日活千万的阅读 App,产品要求支持「夜间模式 + 多套主题」在运行时无缝切换,且不能重建 Activity。当时团队里 View 和 Compose 混用,两套 UI 体系的换肤逻辑完全割裂,每次改主题都要分别维护。
这倒逼我重新设计了一套统一的换肤架构。核心思路:把 Resources 当成一个可替换的抽象层,让 View 体系通过拦截资源加载实现换肤,Compose 体系通过 CompositionLocal 驱动重组。两者共享同一份主题状态,从同一个入口调度。
整体架构:单状态源 + 双通道渲染
先看架构的顶层设计:
┌─────────────────────────────────┐
│ ThemeState (Kotlin) │ ← 全局单例,持有当前主题 ID
└──────────┬──────────────────────┘
│
┌─────┴──────┐
▼ ▼
┌─────────┐ ┌──────────────┐
│ View │ │ Compose │
│ 通道 │ │ 通道 │
│ │ │ │
│Resources│ │CompositionLcl│
│ 替换 │ │ 主题分发 │
└─────────┘ └──────────────┘
ThemeState 用 StateFlow 驱动,View 层通过 addOnPropertyChangedCallback 监听刷新,Compose 层通过 collectAsState 自动重组。两个通道的触发时机完全由这一个状态源控制。
View 通道:拦截 Resources 的资源加载链路
View 体系的换肤本质上是在资源查找阶段做重定向。Android 的资源加载最终都会走到 Resources.getColor()、Resources.getDrawable() 这类方法。替换掉 Resources 实例,就能接管所有资源获取。
创建可替换的 Resources 包装类
class SkinResources(
private val context: Context,
private val skinPkgName: String,
private val skinRes: Resources // 皮肤包的 Resources
) : Resources(context.assets, context.resources.displayMetrics, context.resources.configuration) {
override fun getColor(id: Int, theme: Theme?): Int {
val entryName = context.resources.getResourceEntryName(id)
val skinId = skinRes.getIdentifier(entryName, "color", skinPkgName)
return if (skinId != 0) skinRes.getColor(skinId, theme)
else super.getColor(id, theme) // 兜底到宿主资源
}
override fun getDrawable(id: Int, theme: Theme?): Drawable {
val entryName = context.resources.getResourceEntryName(id)
val skinId = skinRes.getIdentifier(entryName, "drawable", skinPkgName)
return if (skinId != 0) {
val drawable = skinRes.getDrawable(skinId, theme)
// 关键:处理涉及宿主类的 Drawable(如 VectorDrawable)
SkinDrawableProcessor.process(drawable, id)
} else super.getDrawable(id, theme)
}
}
外部 APK 加载的 Drawable,其内部可能引用了宿主 App 的类(比如自定义 VectorDrawable 的子类),直接使用会抛 ClassNotFoundException。需要在上层做反射处理或改用通用基类包装——这个坑后面会细讲。
替换 Context 的 Resources
难点不在创建 SkinResources,而在于全局替换所有 View 使用的 Context。有两个切入点:
切入点一:替换 Application 的 Resources。 直接在 Application 中 setResources(),影响面太大,风险高。
切入点二:替换 Activity 的 Resources。 在 Activity.onCreate() 末尾、super.onCreate() 之后注入。只在 Activity 范围内生效,影响可控:
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
applySkinIfNeeded()
}
private fun applySkinIfNeeded() {
val skinEngine = SkinEngine.instance
if (skinEngine.isSkinApplied) {
resources // 触发 Resources 对象创建
// 反射替换 Activity 内部的 mResources
SkinReflectHelper.setResources(this, skinEngine.skinResources)
}
}
}
需要明确的是,这个反射方案不是一个可靠的万能方案,存在两类局限:
- 版本脆弱性。
mResources只是 AOSP 内部字段名,未写入字段命名约束,在不同厂商 ROM 、不同 Android 大版本上可能发生变化(既有字段重命名,也有结构调整——比如 Resources 获取方式在新版本中转向ResourcesManager统一管理),需要逐版本适配反射路径,并在反射失败时做降级处理,不能假定它在所有机型上都能成功。 - 覆盖不到缓存了 Resources 引用的路径。只替换 Activity 的
mResources字段,并不能自动同步到那些在onCreate之前就已经缓存了旧Resources引用的地方(比如自定义 View 在字段里提前保存了context.resources,或某个单例在初始化时拉取了 Resources 并长期持有)。这类地方需要业务代码自己保证不缓存 Resources 引用,或者在主题切换时主动重新构建这些组件。
实际项目中往往需要搭配开源方案(如 Android-Skin-Loader 、Android-Skin-Support)的兼容性处理,并在接入前在目标机型矩阵上充分回归测试,而不是把反射替换当成一项一次性能覆盖所有情况的技术。
处理 LayoutInflater:劫持 View 创建
换了 Resources 还不够。LayoutInflater 在 inflate XML 时默认用 context.obtainStyledAttributes() 读取属性,但如果 XML 里引用了自定义属性(attr),光换 Resources 不顶用——需要在 Factory2 层面做拦截:
class SkinLayoutFactory(
private val delegate: LayoutInflater.Factory2
) : LayoutInflater.Factory2 {
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
val view = delegate.onCreateView(parent, name, context, attrs) ?: return null
// 从 AttributeSet 中提取换肤相关属性
SkinAttributeParser.parseAndApply(view, attrs)
return view
}
}
SkinAttributeParser 负责解析 background、textColor、src 等属性,记录原始的 resource ID,在主题切换时重新设置。其核心是一个 Map<View, List<SkinAttr>>,记录了每个 View 需要换肤的属性。
Compose 通道:CompositionLocal 驱动声明式主题
Compose 的换肤思路完全不同——它没有 Resources 拦截机制,但提供了更优雅的 CompositionLocal。主题本质上就是一个 data class,通过 CompositionLocalProvider 注入到组合树中。
定义主题数据结构
data class AppTheme(
val colors: ColorScheme,
val typography: TypographyScheme,
val isDark: Boolean
)
// 全局主题值,MaterialTheme 之外的补充
val LocalAppTheme = staticCompositionLocalOf { AppTheme() }
用 staticCompositionLocalOf 而非 compositionLocalOf,主题变化频率低,不需要精确追踪每个组件。
主题切换的触发方式
@Composable
fun ThemeHost(content: @Composable () -> Unit) {
val currentThemeId by ThemeState.themeId.collectAsState()
val theme = remember(currentThemeId) {
ThemeLoader.loadTheme(currentThemeId) // 加载对应主题配置
}
CompositionLocalProvider(
LocalAppTheme provides theme
) {
content()
}
}
ThemeState.themeId 变化时,collectAsState 触发 ThemeHost 重组,所有消费 LocalAppTheme 的组件自动刷新——不需要手动通知任何一个 View。
资源型资源的处理
Compose 里也有一些资源是 XML 定义的(比如 painterResource),这部分无法像 View 通道那样拦截。做法是在加载时动态选择:
@Composable
fun skinPainterResource(@DrawableRes defaultRes: Int): Painter {
val theme = LocalAppTheme.current
return remember(theme) {
val skinResId = SkinBridge.getSkinDrawableId(defaultRes, theme.id)
if (skinResId != 0) painterResource(skinResId)
else painterResource(defaultRes)
}
}
踩坑实录:几个关键细节
坑一:Configuration 变更导致 Resources 被重置。 横竖屏切换或语言切换时,Activity 的 Resources 会被系统重建。需要在 onConfigurationChanged 里重新注入 SkinResources。如果用了 android:configChanges 硬扛,反而不会触发这个问题,但它引入的其他副作用更多,不推荐。
坑二:VectorDrawable 跨包加载。 皮肤 APK 里打包的 VectorDrawable 加载到宿主时,如果引用了宿主 App 的 R.attr 或自定义类,直接崩溃。解决方式是让皮肤包的资源完全自包含,或者在 SkinResources.getDrawable() 里捕获异常后用兜底图。
坑三:Compose 和 View 混用时主题不同步。 ThemeState 更新后,View 通道通过手动遍历 SkinAttributeParser 里记录的 View 列表来刷新,Compose 通过重组自动更新。两者的触发必须在同一个调度入口里完成,否则可能出现短暂的视觉不一致:
fun switchTheme(themeId: Int) {
ThemeState.themeId.value = themeId // 触发 Compose 重组
SkinAttributeParser.applySkinForAllViews() // 触发 View 刷新
}
Compose 有帧率控制,实际渲染是异步的;View 的刷新是同步的。实际效果上两者几乎同时完成,肉眼感知不到差异。
两个实践建议
优先让主题数据类化,而非资源文件化。 不管是 View 还是 Compose,我倾向于在 Kotlin 层定义主题数据结构(ColorScheme、Dimensions 等),资源文件只作为读取入口,不做状态存储。主题切换就是内存中数据结构的切换,延迟几乎为零,无需重新解析 APK。
别盲目追求「运行时加载外部皮肤包」。 加载外部 APK 的 Resources 需要处理签名一致性、ClassLoader 隔离、资源 ID 冲突等问题。如果产品只需要内置 3-5 套主题,用资源冲突方案完全够用(不同 flavor 打包,或 assets 里放配置 JSON + 颜色代码)。只有真正需要用户自定义皮肤或运营动态下发的场景,才值得投入整套 Resources 替换方案。踩过这个坑,后面做需求评估时会务实很多。