布景说明

原本已经根据体系计划适配了暗黑主题,完结了白/黑两套皮肤,以及跟从体系。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟从体系暗黑形式下,用户可选暗黑仍是暗蓝。这不,新的需求立刻就来了。

其实咱们之前两个 App 的换肤计划都是运用 Android-skin-support 来做的,在此根底上再加套皮肤也不是难事。但在新的 App 完结多皮肤时,由于前两个 App 做了这么久都只有两套皮肤,而且新的 App 需求完结跟从体系,为了更好的体会和较少的代码完结,就采用了体系计划进行适配暗黑形式。

以 Android-skin-support 和体系两种计划适配经历来看,体系计划适配改动的代码更少,所花费的时刻当然也就更少了。所以在需求新添一套皮肤的时分,也不可能再去切计划了。那么在运用体系计划的情况下,怎样再加一套皮肤呢?来,先看源码吧。

源码分析

以下源码根据 android-31

首先,在代码中获取资源一般经过 Context 目标的一些办法,例如:

// Context.java
@ColorInt
public final int getColor(@ColorRes int id) {
    return getResources().getColor(id, getTheme());
}
@Nullable
public final Drawable getDrawable(@DrawableRes int id) {
    return getResources().getDrawable(id, getTheme());
}

能够看到 Context 是经过 Resources 目标再去获取的,继续看 Resources

// Resources.java
@ColorInt
public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
    final TypedValue value = obtainTempTypedValue();
    try {
        final ResourcesImpl impl = mResourcesImpl;
        impl.getValue(id, value, true);
        if (value.type >= TypedValue.TYPE_FIRST_INT 
            && value.type <= TypedValue.TYPE_LAST_INT) {
            return value.data;
        } else if (value.type != TypedValue.TYPE_STRING) {
            throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)                                  											+ " type #0x" + Integer.toHexString(value.type) + " is not valid");
        }
        // 这儿调用 ResourcesImpl#loadColorStateList 办法获取色彩
        final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
        return csl.getDefaultColor();
    } finally {
      	releaseTempTypedValue(value);
    }
}
public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme) 
        throws NotFoundException {
    return getDrawableForDensity(id, 0, theme);
}
@Nullable
public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
    final TypedValue value = obtainTempTypedValue();
    try {
        final ResourcesImpl impl = mResourcesImpl;
        impl.getValueForDensity(id, density, value, true);
      	// 看到这儿
        return loadDrawable(value, id, density, theme);
    } finally {
      	releaseTempTypedValue(value);
    }
}
@NonNull
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
Drawable loadDrawable(@NonNull TypedValue value, int id, int density, @Nullable Theme theme)
        throws NotFoundException {
    // 这儿调用 ResourcesImpl#loadDrawable 办法获取 drawable 资源
    return mResourcesImpl.loadDrawable(this, value, id, density, theme);
}

到这儿咱们知道在代码中获取资源时,是经过 Context -> Resources -> ResourcesImpl 调用链完结的。

先看 ResourcesImpl.java

/**
 * The implementation of Resource access. This class contains the AssetManager and all caches
 * associated with it.
 *
 * {@link Resources} is just a thing wrapper around this class. When a configuration change
 * occurs, clients can retain the same {@link Resources} reference because the underlying
 * {@link ResourcesImpl} object will be updated or re-created.
 *
 * @hide
 */
public class ResourcesImpl {
    ...
}

虽然是 public 的类,但是被 @hide 符号了,意味考虑经过承继后重写相关办法这条路行不通了,pass。

再看 Resources.java,同样是 public 类,但没被 @hide 符号。咱们就能够经过承继 Resources 类,然后重写 Resources#getColorResources#getDrawableForDensity 等办法来改造获取资源的逻辑。

先看相关代码:

// SkinResources.kt
class SkinResources(context: Context, res: Resources) : Resources(res.assets, res.displayMetrics, res.configuration) {
    val contextRef: WeakReference<Context> = WeakReference(context)
    override fun getDrawableForDensity(id: Int, density: Int, theme: Theme?): Drawable? {
        return super.getDrawableForDensity(resetResIdIfNeed(contextRef.get(), id), density, theme)
    }
    override fun getColor(id: Int, theme: Theme?): Int {
        return super.getColor(resetResIdIfNeed(contextRef.get(), id), theme)
    }
    private fun resetResIdIfNeed(context: Context?, resId: Int): Int {
        // 非暗黑蓝无需替换资源 ID
        if (context == null || !UIUtil.isNightBlue(context)) return resId
        var newResId = resId
        val res = context.resources
        try {
            val resPkg = res.getResourcePackageName(resId)
            // 非本包资源无需替换
            if (context.packageName != resPkg) return newResId
            val resName = res.getResourceEntryName(resId)
            val resType = res.getResourceTypeName(resId)
          	// 获取对应暗蓝皮肤的资源 id
            val id = res.getIdentifier("${resName}_blue", resType, resPkg)
            if (id != 0) newResId = id
        } finally {
            return newResId
        }
    }
}

首要原理与逻辑:

  • 所有资源都会在 R.java 文件中生成对应的资源 id,而咱们正是经过资源 id 来获取对应资源的。
  • Resources 类供给了 getResourcePackageName/getResourceEntryName/getResourceTypeName 办法,可经过资源 id 获取对应的资源包名/资源称号/资源类型。
  • 过滤掉无需替换资源的场景。
  • Resources 还供给了 getIdentifier 办法来获取对应资源 id。
  • 需求适配暗蓝皮肤的资源,统一在原资源称号的根底上加上 _blue 后缀。
  • 经过 Resources#getIdentifier 办法获取对应暗蓝皮肤的资源 id。假如没找到,改办法会回来 0

现在就能够经过 SkinResources 来获取适配多皮肤的资源了。但是,之前的代码都是经过 Context 直接获取的,假如全部替换成 SkinResources 来获取,那代码改动量就大了。

咱们回到前面 Context.java 的源码,能够发现它获取资源时,都是经过 Context#getResources 办法先得到 Resources 目标,再经过其去获取资源的。而 Context#getResources 办法也是能够重写的,这意味着咱们能够维护一个自己的 Resources 目标。ApplicationActivity 也都是承继自 Context 的,所以咱们在其子类中重写 getResources 办法即可:

// BaseActivity.java/BaseApplication.java
private Resources mSkinResources;
@Override
public Resources getResources() {
    if (mSkinResources == null) {
      	mSkinResources = new SkinResources(this, super.getResources());
    }
    return mSkinResources;
}

到此,基本逻辑就写完了,立刻 build 跑起来。

咦,如同有点不太对劲,有些 colordrawable 没有适配成功。

经过一番对比,发现 xml 布局中的资源都没有替换成功。

那么问题在哪呢?仍是先从源码着手,先来看看 View 是怎样从 xml 中获取并设置 background 特点的:

// View.java
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
  	this(context);
    // AttributeSet 是 xml 中所有特点的集合
    // TypeArray 则是经过处理过的集合,将原始的 xml 特点值("@color/colorBg")转换为所需的类型,并应用主题和款式
    final TypedArray a = context.obtainStyledAttributes(
            attrs, com.android.internal.R.styleable.View, defStyleAttr, defStyleRes);
    ...
    Drawable background = null;
    ...
    final int N = a.getIndexCount();
  	for (int i = 0; i < N; i++) {
      	int attr = a.getIndex(i);
      	switch (attr) {
            case com.android.internal.R.styleable.View_background:
                // TypedArray 供给一些直接获取资源的办法
              	background = a.getDrawable(attr);
              	break;
            ...
        }
    }
    ...
    if (background != null) {
      	setBackground(background);
    }
    ...
}

再接着看 TypedArray 是怎样获取资源的:

// TypedArray.java
@Nullable
public Drawable getDrawable(@StyleableRes int index) {
    return getDrawableForDensity(index, 0);
}
@Nullable
public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
    if (mRecycled) {
      	throw new RuntimeException("Cannot make calls to a recycled instance!");
    }
    final TypedValue value = mValue;
    if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
        if (value.type == TypedValue.TYPE_ATTRIBUTE) {
            throw new UnsupportedOperationException(
                "Failed to resolve attribute at index " + index + ": " + value);
        }
        if (density > 0) {
            // If the density is overridden, the value in the TypedArray will not reflect this.
            // Do a separate lookup of the resourceId with the density override.
            mResources.getValueForDensity(value.resourceId, density, value, true);
        }
      	// 看到这儿
        return mResources.loadDrawable(value, value.resourceId, density, mTheme);
    }
    return null;
}

TypedArray 是经过 Resources#loadDrawable 办法来加载资源的,而咱们之前写 SkinResources 的时分并没有重写该办法,为什么呢?那是由于该办法是被 @UnsupportedAppUsage 符号的。所以,这便是 xml 布局中的资源替换不成功的原因。

这个问题又怎样处理呢?

之前采用 Android-skin-support 计划做换肤时,了解到它的原理,其会替换成自己的完结的 LayoutInflater.Factory2,并在创立 View 时替换生成对应适配了换肤功用的 View 目标。例如:将 View 替换成 SkinView,而 SkinView 初始化时再重新处理 background 特点,即可完结换肤。

AppCompat 也是同样的逻辑,经过 AppCompatViewInflater 将一般的 View 替换成带 AppCompat- 前缀的 View。

其实咱们只需能操作生成后的 View,并且知道 xml 中写了哪些特点值即可。那么咱们彻底照搬 AppCompat 这套逻辑即可:

  • 界说类承继 LayoutInflater.Factory2,并完结 onCreateView 办法。
  • onCreateView 首要是创立 View 的逻辑,而这部分逻辑彻底 copy AppCompatViewInflater 类即可。
  • onCreateView 中创立 View 之后,回来 View 之前,完结咱们自己的逻辑。
  • 经过 LayoutInflaterCompat#setFactory2 办法,设置咱们自己的 Factory2。

相关代码片段:

public class SkinViewInflater implements LayoutInflater.Factory2 {
    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
      	// createView 办法便是 AppCompatViewInflater 中的逻辑
        View view = createView(parent, name, context, attrs, false, false, true, false);
        onViewCreated(context, view, attrs);
        return view;
    }
    @Nullable
    @Override
    public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        return onCreateView(null, name, context, attrs);
    }
    private void onViewCreated(@NonNull Context context, @Nullable View view, @NonNull AttributeSet attrs) {
      	if (view == null) return;
        resetViewAttrsIfNeed(context, view, attrs);
    }
    private void resetViewAttrsIfNeed(Context context, View view, AttributeSet attrs) {
      	if (!UIUtil.isNightBlue(context)) return;
      	String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
      	String BACKGROUND = "background";
      	// 获取 background 特点值的资源 id,未找到时回来 0
      	int backgroundId = attrs.getAttributeResourceValue(ANDROID_NAMESPACE, BACKGROUND, 0);
      	if (backgroundId != 0) {
            view.setBackgroundResource(resetResIdIfNeed(context, backgroundId));
        }
    }
}
// BaseActivity.java
@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    SkinViewInflater inflater = new SkinViewInflater();
    LayoutInflater layoutInflater = LayoutInflater.from(this);
    // 生成 View 的逻辑替换成咱们自己的
    LayoutInflaterCompat.setFactory2(layoutInflater, inflater);
}

至此,这套计划已经能够处理现在的换肤需求了,剩下的便是进行细节适配了。

其他说明

自界说控件与第三方控件适配

上面只对 background 特点进行了处理,其他需求进行换肤的特点也是同样的处理逻辑。假如是自界说的控件,能够在初始化时调用 TypedArray#getResourceId 办法先获取资源 id,再经过 context 去获取对应资源,而不是运用 TypedArray#getDrawable 类似办法直接获取资源目标,这样能够确保换肤成功。而第三方控件也可经过 background 特点同样的处理逻辑进行适配。

XML <shape> 的处理

<!-- bg.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:radius="8dp" />
    <solid android:color="@color/background" />
</shape>

上面的 bg.xml 文件内的 color 并不会完结资源替换,根据上面的逻辑,需求新增以下内容:

<!-- bg_blue.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
  <corners android:radius="8dp" />
  <solid android:color="@color/background_blue" />
</shape>

如此,资源替换才会成功。

设计的合作

这次对第三款皮肤的适配仍是蛮轻松的,首要是有以下根底:

  • 在适配暗黑主题的时分,设计有出设计规范,后续开发依照设计规范来。
  • 暗黑和暗蓝共用一套图片资源,大大削减适配工作量。
  • 暗黑和暗蓝部份共用色彩值含透明度,同样削减了工作量,仅少数色彩需求新增。

这次适配的首要工作量仍是来自 <shape> 的替换。

暗蓝皮肤资源文件的归处

我知道很多换肤计划都会将皮肤资源制作成皮肤包,但是这个计划没有这么做。一是没有那么多需求替换的资源,二是为了削减相应的工作量。

我新建了一个资源文件夹,与 res 同级,取名 res-blue。并在 gradle 装备文件中装备它。编译后体系会主动将它们合并,一起也能与常规资源文件隔离开来。

// build.gradle
sourceSets {
  main {
    java {
        srcDir 'src/main/java'
     }
    res.srcDirs += 'src/main/res'
    res.srcDirs += 'src/main/res-blue'
   }
}

有哪些坑?

WebView 资源缺失导致闪退

版本上线后,发现有 android.content.res.Resources$NotFoundException 反常上报,详细反常堆栈信息:

android.content.res.ResourcesImpl.getValue(ResourcesImpl.java:321)
android.content.res.Resources.getInteger(Resources.java:1279)
org.chromium.ui.base.DeviceFormFactor.b(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.n(chromium-TrichromeWebViewGoogle.apk-stable-447211483:1)
N7.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:8)
Gu.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:2)
com.android.internal.policy.DecorView$ActionModeCallback2Wrapper.onCreateActionMode(DecorView.java:3255)
com.android.internal.policy.DecorView.startActionMode(DecorView.java:1159)
com.android.internal.policy.DecorView.startActionModeForChild(DecorView.java:1115)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.View.startActionMode(View.java:7716)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.I(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vc0.a(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vf0.i(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
A5.run(chromium-TrichromeWebViewGoogle.apk-stable-447211483:3)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loopOnce(Looper.java:233)
android.os.Looper.loop(Looper.java:334)
android.app.ActivityThread.main(ActivityThread.java:8333)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:582)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1065)

经查才发现在 WebView 中长按文本弹出操作菜单时,就会引发该反常导致 App 闪退。

这是其他插件化计划也踩过的坑,咱们只需在创立 SkinResources 之前将外部 WebView 的资源路径添加进来即可。

@Override
public Resources getResources() {
  if (mSkinResources == null) {
      WebViewResourceHelper.addChromeResourceIfNeeded(this);
      mSkinResources = new SkinResources(this, super.getResources());
   }
  return mSkinResources;
}

RePlugin/WebViewResourceHelper.java 源码文件

详细问题分析可参阅

Fix ResourceNotFoundException in Android 7.0 (or above)

终究作用图

基于 Android 系统方案适配 Night Mode 后,老板要再加一套皮肤?

总结

这个计划在原本运用体系方式适配暗黑主题的根底上,经过拦截 Resources 相关获取资源的办法,替换换肤后的资源 id,以到达换肤的作用。针对 XML 布局换肤不成功的问题,复制 AppCompatViewInflater 创立 View 的代码逻辑,并在 View 创立成功后重新设置需求进行换肤的相关 XML 特点。同一皮肤资源运用单独的资源文件夹独立存放,能够与正常资源进行隔离,也避免了制作皮肤包而增加工作量。

现在来说这套计划是改造成本最小,侵入性最小的选择。选择适合本身需求的才是最好的。