深入 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)
        }
    }
}

需要明确的是,这个反射方案不是一个可靠的万能方案,存在两类局限:

  1. 版本脆弱性。mResources 只是 AOSP 内部字段名,未写入字段命名约束,在不同厂商 ROM 、不同 Android 大版本上可能发生变化(既有字段重命名,也有结构调整——比如 Resources 获取方式在新版本中转向 ResourcesManager 统一管理),需要逐版本适配反射路径,并在反射失败时做降级处理,不能假定它在所有机型上都能成功。
  2. 覆盖不到缓存了 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 替换方案。踩过这个坑,后面做需求评估时会务实很多。