一、换肤的业务场景与技术挑战
Android 应用的换肤(Dynamic Theming / Skinning)需求已经超越了”深色模式/浅色模式”的简单切换,演变为复杂的动态主题系统:
- 商业活动:双十一换红色主题、春节换喜庆主题、品牌联名换定制主题。
- 用户付费:付费解锁专属皮肤套装(壁纸、图标、字体颜色统一)。
- AB 测试:不同主题对不同用户群的效果测试。
- APK 瘦身:将非必需的皮肤资源剥离到独立包中,用户按需下载。
- 无障碍适配:高对比度主题、大字体主题。
换肤框架需要解决的三个核心问题:
- 如何拦截系统实例化 View 的过程?——要在 View 创建时就替换其资源引用,而不是创建后再修改(会导致闪烁)。
- 如何确定哪些控件需要替换资源?——并非所有 View 都需要换肤,需要一套标记机制。
- 如何加载外部的资源包?——皮肤资源可以内置在 APK 中,更需要支持从服务器下载的皮肤包。
二、LayoutInflater 的 View 创建机制
要拦截 View 的创建,必须先理解 View 是怎样被创建的。
2.1 setContentView 的调用链
Activity.setContentView(layoutResId) → PhoneWindow.setContentView(layoutResId) → mLayoutInflater.inflate(layoutResId, mContentParent) → LayoutInflater.inflate(resource, root, attachToRoot) → createViewFromTag(root, name, attrs) → Factory2.onCreateView() ← 拦截点! → 如果 Factory 返回 null: → createView(name, prefix, attrs) ← 系统默认创建
|
关键在于 createViewFromTag 方法:
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) {
try { View view = null; if (mFactory2 != null) { view = mFactory2.onCreateView(parent, name, context, attrs); } else if (mFactory != null) { view = mFactory.onCreateView(name, context, attrs); }
if (view == null && mPrivateFactory != null) { view = mPrivateFactory.onCreateView(parent, name, context, attrs); }
if (view == null) { if (prefix == null) { view = createView(name, prefix, attrs); } else if (-1 == name.indexOf('.')) { view = onCreateView(parent, name, attrs); } } return view; } catch (Exception e) { } }
|
关键发现:mFactory2 和 mPrivateFactory 提供了 View 创建的拦截点。如果在 Factory 的 onCreateView() 中返回了一个 View,系统就不会执行后面的默认创建流程。
2.2 AppCompat 如何使用 Factory2
AppCompatDelegate 设置了 LayoutInflater 的 Factory2,通过 AppCompatViewInflater 自动将普通 Button 替换为 AppCompatButton:
@Override public void installViewFactory() { LayoutInflater layoutInflater = LayoutInflater.from(mContext); if (layoutInflater.getFactory() == null) { LayoutInflaterCompat.setFactory2(layoutInflater, this); } else { } }
|
三、Hook LayoutInflater.Factory2 实现换肤
理解了 View 创建流程后,换肤方案就非常清晰了:设置自定义的 Factory2,在 View 创建时记录其「换肤属性」,当皮肤切换时批量更新这些属性。
3.1 核心 Hook 实现
public class SkinFactory implements LayoutInflater.Factory2 { private LayoutInflater.Factory2 mDelegateFactory;
private List<SkinView> mSkinViews = new ArrayList<>();
@Override public View onCreateView(View parent, String name, Context context, AttributeSet attrs) { View view = null; if (mDelegateFactory != null) { view = mDelegateFactory.onCreateView(parent, name, context, attrs); } if (view == null) { view = createView(name, context, attrs); }
if (view != null) { parseSkinAttrs(context, attrs, view); } return view; }
private void parseSkinAttrs(Context context, AttributeSet attrs, View view) { List<SkinAttr> skinAttrs = new ArrayList<>(); for (int i = 0; i < attrs.getAttributeCount(); i++) { String attrName = attrs.getAttributeName(i); String attrValue = attrs.getAttributeValue(i);
if (!isResourceReference(attrValue)) continue;
int resId = extractResourceId(attrValue);
if (isSkinableAttr(attrName)) { String resTypeName = context.getResources().getResourceTypeName(resId); String resEntryName = context.getResources().getResourceEntryName(resId);
skinAttrs.add(new SkinAttr(attrName, resId, resTypeName, resEntryName)); } } if (!skinAttrs.isEmpty()) { mSkinViews.add(new SkinView(view, skinAttrs)); } }
public void applySkin(Resources skinResources, String skinPackageName) { this.mSkinResources = skinResources; this.mSkinPackageName = skinPackageName;
for (SkinView skinView : mSkinViews) { skinView.apply(); } }
class SkinView { View view; List<SkinAttr> skinAttrs;
void apply() { for (SkinAttr attr : skinAttrs) { int skinResId = mSkinResources.getIdentifier( attr.entryName, attr.typeName, mSkinPackageName); if (skinResId == 0) continue;
switch (attr.attrName) { case "background": view.setBackground(mSkinResources.getDrawable(skinResId)); break; case "src": if (view instanceof ImageView) { ((ImageView) view).setImageDrawable( mSkinResources.getDrawable(skinResId)); } break; case "textColor": if (view instanceof TextView) { ((TextView) view).setTextColor( mSkinResources.getColorStateList(skinResId)); } break; } } } } }
|
3.2 安装 Factory2
public class SkinManager { public static void init(Activity activity) { LayoutInflater inflater = LayoutInflater.from(activity);
try { LayoutInflater.Factory2 existingFactory = (LayoutInflater.Factory2) inflater.getFactory();
SkinFactory skinFactory = new SkinFactory(); skinFactory.setDelegateFactory(existingFactory);
setFactory2(inflater, skinFactory); } catch (Exception e) { e.printStackTrace(); } }
private static void setFactory2(LayoutInflater inflater, LayoutInflater.Factory2 factory) { Field factorySetField = LayoutInflater.class.getDeclaredField("mFactorySet"); factorySetField.setAccessible(true); factorySetField.setBoolean(inflater, false);
Field factory2Field = LayoutInflater.class.getDeclaredField("mFactory2"); factory2Field.setAccessible(true); factory2Field.set(inflater, factory);
factorySetField.setBoolean(inflater, true); } }
|
为什么需要反射修改 mFactorySet?
Android 的 LayoutInflater 限制 mFactory2 只能被设置一次(通过 mFactorySet 标记)。但 AppCompatActivity 在 onCreate 中会调用 installViewFactory() 设置自己的 Factory2。如果我们在 onCreate 之后设置自定义 Factory,就会收到异常。解决方案是在 AppCompat 设置之前先占位,或者反射重置 mFactorySet 标记后设置。
四、加载外部皮肤包资源
外部皮肤包是一个独立的 APK 文件,包含 res 目录和 resources.arsc。
public class SkinLoader { public static Resources loadSkinResources(Context context, String skinApkPath) { try { AssetManager assetManager = AssetManager.class.newInstance(); Method addAssetPath = AssetManager.class.getDeclaredMethod( "addAssetPath", String.class); addAssetPath.invoke(assetManager, skinApkPath);
Resources hostResources = context.getResources(); DisplayMetrics dm = hostResources.getDisplayMetrics(); Configuration config = hostResources.getConfiguration();
Resources skinResources = new Resources(assetManager, dm, config);
PackageManager pm = context.getPackageManager(); PackageInfo info = pm.getPackageArchiveInfo(skinApkPath, PackageManager.GET_ACTIVITIES); String skinPackageName = info.packageName;
return skinResources; } catch (Exception e) { throw new RuntimeException("Failed to load skin", e); } } }
|
Android 10+ 对 AssetManager.newInstance() 和 addAssetPath 施加了 hidden API 限制。应对方案:使用 ResourcesProvider API、使用 addAssetPathAsSharedLibrary、或通过 native hook bypass hidden API restrictions。
五、ColorStateList 的替换
文本颜色通常使用 ColorStateList(selector),支持 pressed/enabled/selected 等状态:
public void applyTextColor(TextView textView, int skinColorResId) { ColorStateList colorStateList; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { colorStateList = mSkinResources.getColorStateList( skinColorResId, context.getTheme()); } else { colorStateList = mSkinResources.getColorStateList(skinColorResId); } textView.setTextColor(colorStateList); }
|
六、AndroidX AppCompat 的深色模式实现
AndroidX 从 appcompat:1.1.0 开始内置了深色模式(Dark Theme)支持:
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
|
AppCompat 深色模式的实现原理:
- 在
values/ 下定义浅色主题,在 values-night/ 下定义深色主题。
AppCompatDelegate.setDefaultNightMode() 设置静态标记。
- 在 Activity 创建前,检查标记,设置对应的 Context 配置
uiMode 为 UI_MODE_NIGHT_YES/NO。
- 系统根据
uiMode 加载 values-night/ 或 values/ 目录下的资源。
七、实践注意事项
- 换肤不闪烁:换肤必须在 View 实例化时就完成设置,不能在 View 显示后再
setBackground。
- RecyclerView 复用:列表项在复用后会丢失皮肤属性,需要在
onBindViewHolder 中重新应用。
- 动态添加的 View:通过
new Button(context) 创建的 View 不会经过 LayoutInflater,需要额外的皮肤注册接口。
- Android 版本兼容:不同版本中
AssetManager 和 Resources 的内部实现有差异。
- 资源 ID 冲突:皮肤包的 packageId 应与宿主不同(如 0x7e vs 0x7f),使用 AAPT
--package-id 参数。
八、面试常问题目
Q1: Factory2 的 onCreateView 和系统默认的 createView 的调用顺序是怎样的?
调用顺序:1) mFactory2.onCreateView() — 用户或 AppCompat 设置的 Factory2。2) mFactory.onCreateView() — 兼容旧版的 Factory。3) mPrivateFactory.onCreateView() — Activity 私有 Factory。4) 如果以上都返回 null,系统 createView() 通过反射创建 View(如 android.widget.TextView)。
Q2: 为什么不能直接在 setContentView 之后遍历 View 树来换肤?
遍历 View 树换肤会导致视觉闪烁——View 先以默认资源渲染到屏幕,然后代码遍历并替换资源,中间有可见的切换过程。而 Hook Factory2 在 View 实例化的时候就应用皮肤资源,View 从第一帧显示的就是皮肤资源。
Q3: 皮肤包的资源 ID 与宿主 APK 的资源 ID 冲突怎么办?
资源 ID 格式是 0xPPTTEEEE(PP=packageId, TT=typeId, EEEE=entryId)。宿主 APK 的 packageId 通常是 0x7f。皮肤包的 packageId 需要设置为不同值(如 0x7e),这需要在构建皮肤包时修改 AAPT 打包参数:aapt package --package-id 0x7e ...。
Q4: AppCompat 深色模式和自定义换肤框架可以共存吗?
可以,但需要分层设计。AppCompat 深色模式工作在框架层(通过 values-night/ 和 uiMode 配置),自定义换肤框架工作在 LayoutInflater 的 Factory 层。两者互不干扰。典型策略:(1) 基础颜色由 AppCompat 深色模式控制;(2) 个性化元素(品牌色、活动主题装饰)由自定义换肤框架控制。
Q5: Android 10+ 的 hidden API 限制对换肤框架有什么影响?如何应对?
Android 10+ 通过灰名单(greylist)/黑名单(blacklist)限制了对 hidden API 的反射调用。换肤框架使用的 AssetManager.newInstance() 和 addAssetPath 受到影响。应对方案:(1) 使用 ResourcesProvider API(官方方式);(2) 使用 JNI 绕过 hidden API 限制(通过修改 runtime 的 hidden API 策略标志位);(3) 使用 LSPosed 等框架级的 Hook 方案。
九、Resources.updateConfiguration 动态修改系统配置
除了 LayoutInflater Hook 方案外,另一种换肤思路是通过 Resources.updateConfiguration() 修改系统配置来实现资源替换。这是 Android 框架层级别的方案:
public void applyNightMode(boolean isNight) { Configuration config = getResources().getConfiguration(); int uiMode = isNight ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO; config.uiMode = (config.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | uiMode; getResources().updateConfiguration(config, getResources().getDisplayMetrics()); }
|
updateConfiguration 的局限性:
- 在 Android 8.0+ 被标记为 deprecated(但仍可使用)
- 它不是全局的——只影响调用它的 Context 关联的 Resources 对象
- 触发 Activity 重建(除非声明 configChanges=”uiMode”),造成用户体验中断
十、AssetManager.addAssetPath 的 native 层深度解析
AssetManager.addAssetPath 是换肤框架加载外部皮肤包的底层入口。在 C++ 层,其实现涉及 APK 文件解析和资源索引:
bool AssetManager::addAssetPath(const String8& path, int32_t* cookie) { AutoMutex _l(mLock); asset_path ap; ap.path = path; ap.type = ::kFileTypeRegular; ap.zip = new ZipFileRO(path); ResTable* table = new ResTable(); if (table->add(ap.zip, ...) == NO_ERROR) { mResources.push(table); mAssetPaths.push(ap); *cookie = static_cast<int32_t>(mAssetPaths.size()); return true; } return false; }
|
resources.arsc 文件的结构:
header: ResourceTypes.h 中定义的 ResTable_header string pool: 所有字符串(包名、类型名、entry 名)的全局池 package: 按包名分组的资源 type: 按类型(drawable、string、color 等)分组 entry: 每个资源(由 entryId 标识) value: 资源值(直接值如 #AARRGGBB,或对另一个资源的引用) config: 配置限定符(-night, -zh-rCN, -xhdpi 等)
|
当调用 getIdentifier("bg_button", "drawable", "com.example.skin") 时,AssetManager 内部在 resources.arsc 的 string pool 中查找名称对应的 entryId,再在同一索引表中定位该资源在当前配置下的最佳匹配值。
十一、换肤框架的性能优化
11.1 资源 ID 缓存
每次 getIdentifier() 调用都要在 resources.arsc 的 string pool 中做字符串查找,这是 O(n) 操作。优化方案是建立宿主资源 ID → 皮肤包资源名的映射缓存:
Map<Integer, String> resIdToEntryName = new HashMap<>(); int resId = 0x7f020001; String entryName = "bg_button"; resIdToEntryName.put(resId, entryName);
String name = resIdToEntryName.get(resId); int skinResId = skinResources.getIdentifier(name, "drawable", skinPkgName);
|
11.2 批量 View 更新
大规模换肤时,逐个 View 调用 setBackground/setTextColor 会触发大量重绘。优化方案:
for (SkinView skinView : mSkinViews) { skinView.view.setLayoutTransition(null); skinView.apply(); }
contentView.post(() -> { contentView.requestLayout(); });
|
11.3 异步加载皮肤包
皮肤包可能从网络下载(几 MB 到几十 MB),必须在后台线程加载:
viewModelScope.launch(Dispatchers.IO) { val skinPath = downloadSkin(skinUrl) val skinRes = SkinLoader.loadSkinResources(context, skinPath) withContext(Dispatchers.Main) { skinFactory.applySkin(skinRes, skinPackageName) } }
|
参考源码路径:
- LayoutInflater:
frameworks/base/core/java/android/view/LayoutInflater.java
- PhoneWindow:
frameworks/base/core/java/com/android/internal/policy/PhoneWindow.java
- AppCompatDelegateImpl:
appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java
- AssetManager:
frameworks/base/core/java/android/content/res/AssetManager.java
- Resources:
frameworks/base/core/java/android/content/res/Resources.java
- Android 深色模式:
https://developer.android.com/guide/topics/ui/look-and-feel/darktheme
十二、动态换肤框架的完整架构总结
一个生产级的换肤框架通常包含以下模块:
┌─────────────────────────────────────────────────┐ │ SkinManager (单例,全局入口) │ │ ├── SkinFactory (Hook LayoutInflater.Factory2) │ │ ├── SkinLoader (加载外部皮肤包 Resources) │ │ ├── SkinAttrRegistry (管理换肤属性注册表) │ │ └── SkinCallback (皮肤切换通知接口) │ ├─────────────────────────────────────────────────┤ │ 皮肤包格式:APK (res/ + resources.arsc) │ │ packageId = 0x7e (与宿主 0x7f 区分) │ │ 包含:颜色值、drawable、style、字体等 │ └─────────────────────────────────────────────────┘
|
关键设计决策:
- 皮肤包使用独立 APK(而非 ZIP)——利用 AAPT 的资源编译和环境适配能力
- Hook 时机在 Factory2.onCreateView——确保换肤无闪烁
- 资源替换基于名称匹配(entryName + typeName)——不依赖资源 ID,适配不同构建
- 支持增量换肤——只更新变化的 View 属性,减少重绘开销