基於 Android 系統方案適配 Night Mode 後,老闆要再加一套面板?

語言: CN / TW / HK

背景說明

原本已經基於系統方案適配了暗黑主題,實現了白/黑兩套面板,以及跟隨系統。後來老闆研究學習友商時,發現友商 App 有三套面板可選,除了常規的亮白和暗黑,還有一套暗藍色。並且在跟隨系統暗黑模式下,使用者可選暗黑還是暗藍。這不,新的需求馬上就來了。

其實我們之前兩個 App 的換膚方案都是使用 Android-skin-support 來做的,在此基礎上再加套面板也不是難事。但在新的 App 實現多面板時,由於前兩個 App 做了這麼久都只有兩套面板,而且新的 App 需要實現跟隨系統,為了更好的體驗和較少的程式碼實現,就採用了系統方案進行適配暗黑模式。

Android-skin-support 和系統兩種方案適配經驗來看,系統方案適配改動的程式碼更少,所花費的時間當然也就更少了。所以在需要新添一套面板的時候,也不可能再去切方案了。那麼在使用系統方案的情況下,如何再加一套面板呢?來,先看原始碼吧。

原始碼分析

以下原始碼基於 android-31

首先,在程式碼中獲取資源一般通過 Context 物件的一些方法,例如: ```java // 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: ```java // 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.javajava /** * 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 等方法來改造獲取資源的邏輯。

先看相關程式碼: ```kotlin // 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 方法即可: ```java // 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 屬性的: ```java // 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 是如何獲取資源的: ```java // 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。

相關程式碼片段: ```java 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));
    }
}

} ```

```java // 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> 的處理

```xml

```

上面的 bg.xml 檔案內的 color 並不會完成資源替換,根據上面的邏輯,需要新增以下內容:

```xml

        ```

如此,資源替換才會成功。

設計的配合

這次對第三款面板的適配還是蠻輕鬆的,主要是有以下基礎:

  • 在適配暗黑主題的時候,設計有出設計規範,後續開發按照設計規範來。
  • 暗黑和暗藍共用一套圖片資源,大大減少適配工作量。
  • 暗黑和暗藍部份共用顏色值含透明度,同樣減少了工作量,僅少量顏色需要新增。

這次適配的主要工作量還是來自 <shape> 的替換。

暗藍面板資原始檔的歸處

我知道很多換膚方案都會將面板資源製作成面板包,但是這個方案沒有這麼做。一是沒有那麼多需要替換的資源,二是為了減少相應的工作量。

我新建了一個資原始檔夾,與 res 同級,取名 res-blue。並在 gradle 配置檔案中配置它。編譯後系統會自動將它們合併,同時也能與常規資原始檔隔離開來。

groovy // 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 的資源路徑新增進來即可。

java @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)

最終效果圖

skin_demo.gif

總結

這個方案在原本使用系統方式適配暗黑主題的基礎上,通過攔截 Resources 相關獲取資源的方法,替換換膚後的資源 id,以達到換膚的效果。針對 XML 佈局換膚不成功的問題,複製 AppCompatViewInflater 建立 View 的程式碼邏輯,並在 View 建立成功後重新設定需要進行換膚的相關 XML 屬性。同一面板資源使用單獨的資原始檔夾獨立存放,可以與正常資源進行隔離,也避免了製作面板包而增加工作量。

目前來說這套方案是改造成本最小,侵入性最小的選擇。選擇適合自身需求的才是最好的。