本篇文章是由一个小疑点慢慢扩展出来的需要在探寻的过程中形成的。
在之前的产品中,实现了动态切换 app 主题(主要是整体色调)的功能,在 app 中实际应用多年,此后一直没有再变过需求,因此代码也一直维持没动。它工作得很好。不过,这份代码不止存在于实战 app 中,它还存在于一个测试工程里,问题,就是从测试工程里开始暴露的。
通常来说,对于任何一个工程师,测试工程都不会只有一个,而是会有一堆。“俺也一样”。这些代码短期无用,抛弃可惜。于是老夫就计划把它们拢到一个工程里,上一段提到的那个测试工程,被我选定为了基础工程,至于其它测试工程,只把其中的技术点并入到基础工程中后就要废弃了。一次合并某个 Activity 后,要查看一下相同的代码在原先的工程中和迁移后的工程中表现是否保持一致,发现略有不同:原先的测试工程中的 Activity,显示了 ActionBar,而合并到基础工程中以后,ActionBar 不显示了。由于 ActionBar 是集成环境的向导生成框架代码时的自动产物,也没有实际用到,所以当时只是感觉奇怪了一下,也就没有深究。
可问题前两天又一次出现了,这次是迁移了另一个 Activity。跟前次不同的在于,这个 Activity 里有的操作入口在菜单里,而菜单的入口在 ActionBar 上。可这 ActionBar 不明不白地玩儿失踪,我还怎么测功能?到 manifest 里面修改 theme 属性,指定为明确的带有 ActionBar 的主题,一点作用都没有。在 onCreate 里使用 getSupportActionBar 方法检查,把把都是返回 null。这时,我突然想到了那个一直被忽略的 ThemeManager。到 Application 类里一看,果然有它的初始化,注释掉之后,ActionBar 回来了。再一查,它掌控的数十个不同色调的主题,都派生于一个以系统的 NoActionBar 系的主题为父主题的基础主题。
真相终于大白。ThemeManager 监控了 app 内所有 Activity 的生命周期,在创建之初就会一视同仁地将主题设置为它自己的,而没有顾及要兼容 Activity 原先主题内 ActionBar 的有无。当我询问 AI 可以怎么解决时,AI 的第一反馈是:你可以创建一批带有 ActionBar 的主题供 ThemeManager 使用。一下把我惊呆了,几十个呢,全都复制一遍?就是改个根主题?幸亏老夫还没傻糊涂,又变着法儿问了一次,于是有了以下的几个选择。
Q:之前为了能够切换主题色,做了数十个主题,都是以 NoActionBar 为基础的,现在要兼容带有 ActionBar 的,难道只能把这几十个主题都复制一份,而仅仅是把 parent 改成另一个?
A:你的顾虑非常合理!不需要为每个 NoActionBar 主题手动复制一份带 ActionBar 的变体。以下是高效且可维护的解决方案:
方案一:使用主题继承 + 覆盖属性(推荐)
1. 定义基础主题层级
1 2 3 4 5 6 7 8 9 10 11 |
<!-- 基础主题(无 ActionBar) --> <style name="Theme.App.Base" parent="Theme.Material3.Light.NoActionBar"> <item name="colorPrimary">@color/primary</item> <!-- 其他通用属性 --> </style> <!-- 带 ActionBar 的基础变体 --> <style name="Theme.App.Base.WithActionBar" parent="Theme.Material3.Light"> <!-- 继承所有基础属性 --> <item name="colorPrimary">@color/primary</item> </style> |
2. 动态生成主题
在代码中通过 主题叠加 动态生成最终主题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
public static void applyTheme(Activity activity) { boolean needActionBar = hasOriginalActionBar(activity); int baseTheme = needActionBar ? R.style.Theme_App_Base_WithActionBar : R.style.Theme_App_Base; // 动态创建叠加主题 ContextThemeWrapper wrapper = new ContextThemeWrapper( activity, baseTheme ); // 应用当前颜色配置 TypedArray ta = wrapper.obtainStyledAttributes(new int[]{ R.attr.colorPrimary, R.attr.colorSecondary }); int colorPrimary = ta.getColor(0, Color.BLACK); ta.recycle(); // 设置到 Activity activity.setTheme(baseTheme); if (needActionBar) { activity.getWindow().setStatusBarColor(colorPrimary); } } |
方案二:使用 ThemeOverlay 动态覆盖
1. 定义 ActionBar 覆盖层
1 2 3 4 5 |
<!-- 覆盖层:添加 ActionBar 支持 --> <style name="ThemeOverlay.AddActionBar" parent=""> <item name="windowActionBar">true</item> <item name="windowNoTitle">false</item> </style> |
2. 动态组合主题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// 获取当前主题资源 ID(假设是某个 NoActionBar 主题) int currentTheme = R.style.Theme_App_Red_NoActionBar; // 创建动态主题配置 ContextThemeWrapper wrapper = new ContextThemeWrapper( activity, currentTheme ); // 如果需要 ActionBar,叠加覆盖层 if (needActionBar) { wrapper.applyStyle(R.style.ThemeOverlay_AddActionBar, true); } // 应用到 Activity activity.setTheme(wrapper.getTheme()); |
方案三:使用 XML 的 parent 链式继承
1. 利用抽象中间层
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<!-- 所有主题的抽象父级 --> <style name="Theme.App.Abstract" parent=""> <item name="colorPrimary">@color/primary</item> <!-- 其他共享属性 --> </style> <!-- 具体实现层 --> <style name="Theme.App.Red" parent="Theme.App.Abstract"> <item name="colorPrimary">@color/red</item> </style> <!-- 运行时动态决定最终 parent --> <style name="Theme.App.Red.Final" parent="Theme.App.Red"> <!-- 空实现,由代码决定 parent --> </style> |
2. 代码中动态指定parent
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 在 ThemeManager 中 int finalParent = needActionBar ? android.R.style.Theme_Material : android.R.style.Theme_Material_NoActionBar; // 通过反射动态修改 parent Resources.Theme theme = activity.getTheme(); try { Field mThemeResId = theme.getClass().getDeclaredField("mThemeResId"); mThemeResId.setAccessible(true); mThemeResId.set(theme, R.style.Theme_App_Red_Final); Field mTheme = theme.getClass().getDeclaredField("mTheme"); mTheme.setAccessible(true); ((ResourcesImpl.ThemeImpl)mTheme.get(theme)).setBase(finalParent); } catch (Exception e) { // 降级处理 } |
对比方案优劣
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
继承+覆盖 | 无反射,兼容性好 | 需维护基础主题 | 大多数项目 |
ThemeOverlay | 灵活度高 | 需处理属性冲突 | 需要精细控制时 |
动态parent | 最少的重复代码 | 反射有风险 | 激进的技术栈 |
终极建议:方案一 + 代码生成
-
使用 Python/Gradle 脚本 自动生成带 ActionBar 的变体:
12345678# build-themes.pyfor color in ["red", "blue", "green"]:with open(f"res/values/themes_{color}.xml", "w") as f:f.write(f"""<style name="Theme.App.{color}" parent="Theme.App.Base"/><style name="Theme.App.{color}.WithActionBar"parent="Theme.App.Base.WithActionBar"/>""") -
在 build.gradle 中配置:
1234task generateThemes(type: Exec) {commandLine 'python', 'build-themes.py'}preBuild.dependsOn generateThemes这样既能保持代码整洁,又避免了手动维护重复主题。
其实吧,出于老夫的审美,显然更倾向于方案二或者三。