目录
  1. 1. 一、换肤的业务场景与技术挑战
  2. 2. 二、LayoutInflater 的 View 创建机制
    1. 2.1. 2.1 setContentView 的调用链
    2. 2.2. 2.2 AppCompat 如何使用 Factory2
  3. 3. 三、Hook LayoutInflater.Factory2 实现换肤
    1. 3.1. 3.1 核心 Hook 实现
    2. 3.2. 3.2 安装 Factory2
  4. 4. 四、加载外部皮肤包资源
  5. 5. 五、ColorStateList 的替换
  6. 6. 六、AndroidX AppCompat 的深色模式实现
  7. 7. 七、实践注意事项
  8. 8. 八、面试常问题目
  9. 9. 九、Resources.updateConfiguration 动态修改系统配置
  10. 10. 十、AssetManager.addAssetPath 的 native 层深度解析
  11. 11. 十一、换肤框架的性能优化
    1. 11.1. 11.1 资源 ID 缓存
    2. 11.2. 11.2 批量 View 更新
    3. 11.3. 11.3 异步加载皮肤包
  12. 12. 十二、动态换肤框架的完整架构总结
深入理解Hook换肤

一、换肤的业务场景与技术挑战

Android 应用的换肤(Dynamic Theming / Skinning)需求已经超越了”深色模式/浅色模式”的简单切换,演变为复杂的动态主题系统:

  • 商业活动:双十一换红色主题、春节换喜庆主题、品牌联名换定制主题。
  • 用户付费:付费解锁专属皮肤套装(壁纸、图标、字体颜色统一)。
  • AB 测试:不同主题对不同用户群的效果测试。
  • APK 瘦身:将非必需的皮肤资源剥离到独立包中,用户按需下载。
  • 无障碍适配:高对比度主题、大字体主题。

换肤框架需要解决的三个核心问题:

  1. 如何拦截系统实例化 View 的过程?——要在 View 创建时就替换其资源引用,而不是创建后再修改(会导致闪烁)。
  2. 如何确定哪些控件需要替换资源?——并非所有 View 都需要换肤,需要一套标记机制。
  3. 如何加载外部的资源包?——皮肤资源可以内置在 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 方法:

// AOSP: frameworks/base/core/java/android/view/LayoutInflater.java
View createViewFromTag(View parent, String name, Context context,
AttributeSet attrs, boolean ignoreThemeAttr) {

// ... 处理 <blink> 等特殊标签 ...

try {
View view = null;
// 1. 先尝试通过 mFactory2 创建(关键拦截点)
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) {
// 2. 再尝试通过 mPrivateFactory 创建(AppCompatActivity 设置)
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}

if (view == null) {
// 3. 都没有,系统默认创建
if (prefix == null) {
view = createView(name, prefix, attrs);
} else if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs); // 添加 android.view. 前缀
}
}
return view;
} catch (Exception e) {
// ...
}
}

关键发现mFactory2mPrivateFactory 提供了 View 创建的拦截点。如果在 Factory 的 onCreateView() 中返回了一个 View,系统就不会执行后面的默认创建流程。

2.2 AppCompat 如何使用 Factory2

AppCompatDelegate 设置了 LayoutInflater 的 Factory2,通过 AppCompatViewInflater 自动将普通 Button 替换为 AppCompatButton:

// androidx.appcompat.app.AppCompatDelegateImpl.java
@Override
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(mContext);
if (layoutInflater.getFactory() == null) {
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else {
// 如果已经有 Factory,不能覆盖(Android 限制:只能设置一次)
}
}

三、Hook LayoutInflater.Factory2 实现换肤

理解了 View 创建流程后,换肤方案就非常清晰了:设置自定义的 Factory2,在 View 创建时记录其「换肤属性」,当皮肤切换时批量更新这些属性

3.1 核心 Hook 实现

public class SkinFactory implements LayoutInflater.Factory2 {
// 系统已有的 Factory2(通常来自 AppCompat)
private LayoutInflater.Factory2 mDelegateFactory;

// 记录需要换肤的 View 及其属性
private List<SkinView> mSkinViews = new ArrayList<>();

@Override
public View onCreateView(View parent, String name, Context context,
AttributeSet attrs) {
// 1. 先让系统的 Factory 创建 View(保持 AppCompat 的兼容性)
View view = null;
if (mDelegateFactory != null) {
view = mDelegateFactory.onCreateView(parent, name, context, attrs);
}
if (view == null) {
view = createView(name, context, attrs);
}

// 2. 解析换肤属性并记录
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);

// 只关心引用类型的资源(@drawable/xxx, @color/xxx)
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();
}
}

// 内部类:封装一个需要换肤的 View 及其属性列表
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 {
// 获取 AppCompat 已经设置的 Factory2 作为 delegate
LayoutInflater.Factory2 existingFactory =
(LayoutInflater.Factory2) inflater.getFactory();

// 创建自定义 SkinFactory,委派系统创建,自行管理换肤
SkinFactory skinFactory = new SkinFactory();
skinFactory.setDelegateFactory(existingFactory);

// 反射替换(因为 Android 只允许 setFactory2 一次)
setFactory2(inflater, skinFactory);
} catch (Exception e) {
e.printStackTrace();
}
}

private static void setFactory2(LayoutInflater inflater,
LayoutInflater.Factory2 factory) {
// 反射修改 mFactorySet 为 false,然后替换 mFactory2
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 {
// 1. 创建新的 AssetManager 并添加皮肤 APK 路径
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = AssetManager.class.getDeclaredMethod(
"addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinApkPath);

// 2. 使用宿主资源的显示和配置信息
Resources hostResources = context.getResources();
DisplayMetrics dm = hostResources.getDisplayMetrics();
Configuration config = hostResources.getConfiguration();

// 3. 创建皮肤包的 Resources 对象
Resources skinResources = new Resources(assetManager, dm, config);

// 4. 获取皮肤包的 packageName
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 深色模式的实现原理:

  1. values/ 下定义浅色主题,在 values-night/ 下定义深色主题。
  2. AppCompatDelegate.setDefaultNightMode() 设置静态标记。
  3. 在 Activity 创建前,检查标记,设置对应的 Context 配置 uiModeUI_MODE_NIGHT_YES/NO
  4. 系统根据 uiMode 加载 values-night/values/ 目录下的资源。

七、实践注意事项

  1. 换肤不闪烁:换肤必须在 View 实例化时就完成设置,不能在 View 显示后再 setBackground
  2. RecyclerView 复用:列表项在复用后会丢失皮肤属性,需要在 onBindViewHolder 中重新应用。
  3. 动态添加的 View:通过 new Button(context) 创建的 View 不会经过 LayoutInflater,需要额外的皮肤注册接口。
  4. Android 版本兼容:不同版本中 AssetManagerResources 的内部实现有差异。
  5. 资源 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;

// updateConfiguration 会触发所有 Activity 的重建(除非声明了 configChanges)
getResources().updateConfiguration(config, getResources().getDisplayMetrics());
}

updateConfiguration 的局限性

  • 在 Android 8.0+ 被标记为 deprecated(但仍可使用)
  • 它不是全局的——只影响调用它的 Context 关联的 Resources 对象
  • 触发 Activity 重建(除非声明 configChanges=”uiMode”),造成用户体验中断

十、AssetManager.addAssetPath 的 native 层深度解析

AssetManager.addAssetPath 是换肤框架加载外部皮肤包的底层入口。在 C++ 层,其实现涉及 APK 文件解析和资源索引:

// frameworks/base/libs/androidfw/AssetManager.cpp
bool AssetManager::addAssetPath(const String8& path, int32_t* cookie)
{
// 1. 检查路径是否为 zip 文件(APK)
// 2. 如果是 APK,解析 resources.arsc 文件
// — resources.arsc 是编译过的资源索引表(二进制格式)
// 3. 加载 res/ 目录中的资源文件
// 4. 将新的 AssetPath 添加到 mAssetPaths 列表
// 5. 返回 cookie(用于后续区分不同 APK 的资源)

AutoMutex _l(mLock);
asset_path ap;
ap.path = path;
ap.type = ::kFileTypeRegular;
// 打开 APK 作为 zip 文件
ap.zip = new ZipFileRO(path);
// 解析 resources.arsc
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 → 皮肤包资源名的映射缓存:

// 在 SkinFactory.parseSkinAttrs 中建立缓存
Map<Integer, String> resIdToEntryName = new HashMap<>();
int resId = 0x7f020001;
String entryName = "bg_button";
resIdToEntryName.put(resId, entryName);

// 换肤时直接查缓存,避免 getResourceEntryName 调用
String name = resIdToEntryName.get(resId);
int skinResId = skinResources.getIdentifier(name, "drawable", skinPkgName);

11.2 批量 View 更新

大规模换肤时,逐个 View 调用 setBackground/setTextColor 会触发大量重绘。优化方案:

// 1. 冻结布局请求
for (SkinView skinView : mSkinViews) {
skinView.view.setLayoutTransition(null); // 禁用过渡动画
skinView.apply(); // 更新属性
}

// 2. 统一触发一次重绘
contentView.post(() -> {
contentView.requestLayout(); // 触发一次完整的布局+绘制
});

11.3 异步加载皮肤包

皮肤包可能从网络下载(几 MB 到几十 MB),必须在后台线程加载:

// 使用协程或线程池异步加载
viewModelScope.launch(Dispatchers.IO) {
// 1. 下载皮肤包到本地
val skinPath = downloadSkin(skinUrl)
// 2. 加载皮肤 Resources
val skinRes = SkinLoader.loadSkinResources(context, skinPath)
// 3. 切回主线程应用皮肤
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、字体等 │
└─────────────────────────────────────────────────┘

关键设计决策

  1. 皮肤包使用独立 APK(而非 ZIP)——利用 AAPT 的资源编译和环境适配能力
  2. Hook 时机在 Factory2.onCreateView——确保换肤无闪烁
  3. 资源替换基于名称匹配(entryName + typeName)——不依赖资源 ID,适配不同构建
  4. 支持增量换肤——只更新变化的 View 属性,减少重绘开销
打赏
  • 微信
  • 支付宝

评论