目录
  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. 八、面试常问题目
深入理解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(实际在 AppCompatDelegateImpl 中)设置了 LayoutInflater 的 Factory2,通过 AppCompatViewInflater 自动将普通的 Button 替换为 AppCompatButton,将 TextView 替换为 AppCompatTextView:

// 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 限制:只能设置一次)
}
}

@Override
public final View onCreateView(View parent, String name, Context context,
AttributeSet attrs) {
return createView(parent, name, context, attrs);
}

// 最终的 View 创建逻辑在 AppCompatViewInflater
public final View createView(View parent, String name, @NonNull Context context,
@NonNull AttributeSet attrs, ...) {
switch (name) {
case "TextView":
view = new AppCompatTextView(context, attrs);
break;
case "Button":
view = new AppCompatButton(context, attrs);
break;
// ... ImageView, EditText, CheckBox 等
default:
view = null;
}
return view;
}

三、Hook LayoutInflater.Factory2 实现换肤

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

3.1 核心 Hook 实现

public class SkinFactory implements LayoutInflater.Factory2 {
private static final String TAG = "SkinFactory";

// 系统已有的 Factory2(通常来自 AppCompat)
private LayoutInflater.Factory2 mDelegateFactory;

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

// 皮肤包的 Resources 对象
private Resources mSkinResources;
private String mSkinPackageName; // 皮肤包的 packageName

@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) {
// 如果 delegate 没有创建,自己创建
view = createView(name, context, attrs);
}

// 2. 解析换肤属性并记录
if (view != null) {
parseSkinAttrs(context, attrs, view);
}
return view;
}

@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return onCreateView(null, name, context, attrs);
}

/**
* 解析 View 的属性,找出需要换肤的属性并记录到 SkinView 中
*/
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;
}

// 提取资源 id
int resId = extractResourceId(attrValue); // 如 "@2131234567" → 2131234567

// 判断是否是换肤关注的属性
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;

SkinView(View view, List<SkinAttr> attrs) {
this.view = view;
this.skinAttrs = attrs;
}

void apply() {
for (SkinAttr attr : skinAttrs) {
// 在皮肤包中查找同名资源
int skinResId = mSkinResources.getIdentifier(
attr.entryName, // 资源名(如 "bg_button")
attr.typeName, // 资源类型(如 "drawable")
mSkinPackageName // 皮肤包的 packageName
);

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;
// 更多属性:textSize, tint, colorBackground 等
}
}
}
}
}

3.2 安装 Factory2

public class SkinManager {
public static void init(Activity activity) {
LayoutInflater inflater = LayoutInflater.from(activity);

// 关键:LayoutInflater 的 Factory 只能设置一次
// 必须在 super.onCreate() 之前设置,否则 AppCompat 会先设置
// 解决方案:反射修改 LayoutInflater 的 mFactorySet 字段
try {
// 获取 AppCompat 已经设置的 Factory2 作为 delegate
LayoutInflater.Factory2 existingFactory = (LayoutInflater.Factory2) inflater.getFactory();

// 创建自定义 SkinFactory
SkinFactory skinFactory = new SkinFactory();
skinFactory.setDelegateFactory(existingFactory);

// 反射替换
setFactory2(inflater, skinFactory);
} catch (Exception e) {
e.printStackTrace();
}
}

private static void setFactory2(LayoutInflater inflater, LayoutInflater.Factory2 factory) {
// 反射修改 mFactorySet 为 false,然后调用 setFactory2
try {
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);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

为什么需要反射修改 mFactorySet?

Android 的 LayoutInflater 限制 mFactory2 只能被设置一次(通过 mFactorySet 标记)。但 AppCompatActivity 在 onCreate 中会调用 installViewFactory() 设置自己的 Factory2。如果我们在 onCreate 之后设置自定义 Factory,就会收到异常。解决方案是在 AppCompat 设置之前先占位,或者反射重置 mFactorySet 标记后设置。更优雅的做法是在 Application 的 registerActivityLifecycleCallbacks 中,在 onActivityCreated 之前(通过 onActivityCreated 方法中调用 setFactory2 然后调用 super.onCreate() 之前拦截)。

另一种方案:通过 Hook AppCompatDelegate.setCompatVectorFromResourcesEnabled() 的时机,在 AppCompat 的 Factory 中嵌入换肤逻辑,而不替换整个 Factory。

四、加载外部皮肤包资源

外部皮肤包是一个独立的 APK 文件(可以从服务器下载),包含 res 目录和 resources.arsc。

public class SkinLoader {
/**
* 从指定的 APK 路径加载皮肤资源
*/
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(API 29)之后,AssetManager.newInstance()addAssetPath 被限制(hidden API),需要使用 ResourcesProvider 方式:

// Android 10+ 的官方方式(仍可能被过滤)
// 或使用 addAssetPathAsSharedLibrary 绕过限制

五、ColorStateList 的替换

文本颜色通常使用 ColorStateList(selector),支持 pressed/enabled/selected 等状态:

// 替换 TextView 的文本颜色
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); // 跟随系统

// 每个 Activity 在 onCreate 中需要
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);

AppCompat 深色模式的实现原理:

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

这种方式相比于自定义 Hook 换肤方案,优势是系统原生支持、无需反射、完全符合 Android 资源框架。但缺点是只能切换内置的两套资源(白天/黑夜),不支持动态下载的皮肤包。

七、实践注意事项

  1. 换肤不闪烁:换肤必须在 View 实例化时就完成设置,不能在 View 显示后再 setBackground,否则会有视觉闪烁。这就是为什么要 Hook onCreateView 而不是事后遍历 View 树。
  2. RecyclerView 复用:列表项在复用后会丢失皮肤属性,需要在 onBindViewHolder 中重新应用。
  3. 动态添加的 View:通过 new Button(context) 创建的 View 不会经过 LayoutInflater,需要额外的皮肤注册接口。
  4. WebView 换肤:WebView 的内容无法通过 Android 换肤框架控制,需要前端配合使用模板变量。
  5. Android 版本兼容:不同 Android 版本中 AssetManagerResources 的内部实现有差异,需要仔细处理兼容性(特别是 Android 9+ 对 hidden API 的限制)。

八、面试常问题目

Q1: Factory2 的 onCreateView 和系统默认的 createView 的调用顺序是怎样的?

调用 order 是:

  1. mFactory2.onCreateView() — 用户或 AppCompat 设置的 Factory2。
  2. mFactory.onCreateView() — 兼容旧版的 Factory(已废弃,但仍保留)。
  3. mPrivateFactory.onCreateView() — Activity 私有 Factory(AppCompat 通常在 onCreate 中设置它)。
  4. 如果以上都返回 null,系统 createView() 通过反射创建 View(如 android.widget.TextView)。
    拦截点就在第 1 步和第 3 步,返回非 null 的 View 即可阻止后面步骤。

Q2: 为什么不能直接在 setContentView 之后遍历 View 树来换肤?

遍历 View 树换肤会导致视觉闪烁——View 先以默认资源渲染到屏幕,然后代码遍历并替换资源,中间有可见的切换过程。而 Hook Factory2 在 View 实例化的时候就应用皮肤资源,View 从第一帧显示的就是皮肤资源。另外,某些 View 属性(如 background)在设置后会触发重绘,遍历大规模 View 树可能导致明显的卡顿。

Q3: 皮肤包的资源 ID 与宿主 APK 的资源 ID 冲突怎么办?

资源 ID 格式是 0xPPTTEEEE(PP=packageId, TT=typeId, EEEE=entryId)。宿主 APK 的 packageId 通常是 0x7f。皮肤包的 packageId 需要设置为不同值(如 0x7e),这样两组资源 ID 就不会冲突。这需要在构建皮肤包时修改 AAPT 打包参数:aapt package --package-id 0x7e ...

Q4: AppCompat 深色模式和自定义换肤框架可以共存吗?

可以,但需要分层设计。AppCompat 深色模式工作在框架层(通过 values-night/uiMode 配置),自定义换肤框架工作在 LayoutInflater 的 Factory 层。两者互不干扰。典型的分层策略是:(1) 基础颜色由 AppCompat 深色模式控制(?attr/colorSurface?attr/colorOnSurface 等),保证所有组件响应深色模式;(2) 个性化元素(品牌色、活动主题装饰)由自定义换肤框架控制。


参考源码路径:

  • 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
  • AppCompatViewInflater:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatViewInflater.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
打赏
  • 微信
  • 支付宝

评论