一、换肤的业务场景与技术挑战
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(实际在 AppCompatDelegateImpl 中)设置了 LayoutInflater 的 Factory2,通过 AppCompatViewInflater 自动将普通的 Button 替换为 AppCompatButton,将 TextView 替换为 AppCompatTextView:
@Override public void installViewFactory() { LayoutInflater layoutInflater = LayoutInflater.from(mContext); if (layoutInflater.getFactory() == null) { LayoutInflaterCompat.setFactory2(layoutInflater, this); } else { } }
@Override public final View onCreateView(View parent, String name, Context context, AttributeSet attrs) { return createView(parent, name, context, attrs); }
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; 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";
private LayoutInflater.Factory2 mDelegateFactory;
private List<SkinView> mSkinViews = new ArrayList<>();
private Resources mSkinResources; private String mSkinPackageName;
@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; }
@Override public View onCreateView(String name, Context context, AttributeSet attrs) { return onCreateView(null, name, context, attrs); }
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;
SkinView(View view, List<SkinAttr> attrs) { this.view = view; this.skinAttrs = attrs; }
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) { 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 {
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(API 29)之后,AssetManager.newInstance() 和 addAssetPath 被限制(hidden API),需要使用 ResourcesProvider 方式:
五、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);
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
AppCompat 深色模式的实现原理:
- 在
values/ 下定义浅色主题,在 values-night/ 下定义深色主题。
AppCompatDelegate.setDefaultNightMode() 设置一个静态标记(存储到 AppCompatDelegate.sDefaultNightMode)。
- 在 Activity 创建前(
AppCompatDelegateImpl.create()),检查标记,设置对应的 Context 配置 uiMode 为 UI_MODE_NIGHT_YES/NO。
- 系统根据
uiMode 加载 values-night/ 或 values/ 目录下的资源。
这种方式相比于自定义 Hook 换肤方案,优势是系统原生支持、无需反射、完全符合 Android 资源框架。但缺点是只能切换内置的两套资源(白天/黑夜),不支持动态下载的皮肤包。
七、实践注意事项
- 换肤不闪烁:换肤必须在 View 实例化时就完成设置,不能在 View 显示后再
setBackground,否则会有视觉闪烁。这就是为什么要 Hook onCreateView 而不是事后遍历 View 树。
- RecyclerView 复用:列表项在复用后会丢失皮肤属性,需要在
onBindViewHolder 中重新应用。
- 动态添加的 View:通过
new Button(context) 创建的 View 不会经过 LayoutInflater,需要额外的皮肤注册接口。
- WebView 换肤:WebView 的内容无法通过 Android 换肤框架控制,需要前端配合使用模板变量。
- Android 版本兼容:不同 Android 版本中
AssetManager 和 Resources 的内部实现有差异,需要仔细处理兼容性(特别是 Android 9+ 对 hidden API 的限制)。
八、面试常问题目
Q1: Factory2 的 onCreateView 和系统默认的 createView 的调用顺序是怎样的?
调用 order 是:
mFactory2.onCreateView() — 用户或 AppCompat 设置的 Factory2。
mFactory.onCreateView() — 兼容旧版的 Factory(已废弃,但仍保留)。
mPrivateFactory.onCreateView() — Activity 私有 Factory(AppCompat 通常在 onCreate 中设置它)。
- 如果以上都返回 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