背景說(shuō)明
原本已經(jīng)基于系統(tǒng)方案適配了暗黑主題懈凹,實(shí)現(xiàn)了白/黑兩套皮膚,以及跟隨系統(tǒng)悄谐。后來(lái)老板研究學(xué)習(xí)友商時(shí)介评,發(fā)現(xiàn)友商 App 有三套皮膚可選,除了常規(guī)的亮白和暗黑爬舰,還有一套暗藍(lán)色们陆。并且在跟隨系統(tǒng)暗黑模式下,用戶可選暗黑還是暗藍(lán)情屹。這不坪仇,新的需求馬上就來(lái)了。
其實(shí)我們之前兩個(gè) App 的換膚方案都是使用 Android-skin-support 來(lái)做的垃你,在此基礎(chǔ)上再加套皮膚也不是難事椅文。但在新的 App 實(shí)現(xiàn)多皮膚時(shí)喂很,由于前兩個(gè) App 做了這么久都只有兩套皮膚,而且新的 App 需要實(shí)現(xiàn)跟隨系統(tǒng)皆刺,為了更好的體驗(yàn)和較少的代碼實(shí)現(xiàn)少辣,就采用了系統(tǒng)方案進(jìn)行適配暗黑模式。
以 Android-skin-support 和系統(tǒng)兩種方案適配經(jīng)驗(yàn)來(lái)看羡蛾,系統(tǒng)方案適配改動(dòng)的代碼更少漓帅,所花費(fèi)的時(shí)間當(dāng)然也就更少了。所以在需要新添一套皮膚的時(shí)候痴怨,也不可能再去切方案了忙干。那么在使用系統(tǒng)方案的情況下,如何再加一套皮膚呢浪藻?來(lái)捐迫,先看源碼吧。
源碼分析
以下源碼基于 android-31
首先爱葵,在代碼中獲取資源一般通過(guò) Context
對(duì)象的一些方法弓乙,例如:
// 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
是通過(guò) Resources
對(duì)象再去獲取的,繼續(xù)看 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");
}
// 這里調(diào)用 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 {
// 這里調(diào)用 ResourcesImpl#loadDrawable 方法獲取 drawable 資源
return mResourcesImpl.loadDrawable(this, value, id, density, theme);
}
到這里我們知道在代碼中獲取資源時(shí)钧惧,是通過(guò) Context
-> Resources
-> ResourcesImpl
調(diào)用鏈實(shí)現(xiàn)的。
先看 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
標(biāo)記了浓瞪,意味著想通過(guò)繼承后重寫相關(guān)方法這條路行不通了,pass巧婶。
再看 Resources.java
乾颁,同樣是 public
類,但沒(méi)被 @hide
標(biāo)記艺栈。我們就可以通過(guò)繼承 Resources
類英岭,然后重寫 Resources#getColor
和 Resources#getDrawableForDensity
等方法來(lái)改造獲取資源的邏輯。
先看相關(guān)代碼:
// 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 {
// 非暗黑藍(lán)無(wú)需替換資源 ID
if (context == null || !UIUtil.isNightBlue(context)) return resId
var newResId = resId
val res = context.resources
try {
val resPkg = res.getResourcePackageName(resId)
// 非本包資源無(wú)需替換
if (context.packageName != resPkg) return newResId
val resName = res.getResourceEntryName(resId)
val resType = res.getResourceTypeName(resId)
// 獲取對(duì)應(yīng)暗藍(lán)皮膚的資源 id
val id = res.getIdentifier("${resName}_blue", resType, resPkg)
if (id != 0) newResId = id
} finally {
return newResId
}
}
}
主要原理與邏輯:
- 所有資源都會(huì)在
R.java
文件中生成對(duì)應(yīng)的資源 id湿右,而我們正是通過(guò)資源 id 來(lái)獲取對(duì)應(yīng)資源的诅妹。 -
Resources
類提供了getResourcePackageName
/getResourceEntryName
/getResourceTypeName
方法,可通過(guò)資源 id 獲取對(duì)應(yīng)的資源包名/資源名稱/資源類型毅人。 - 過(guò)濾掉無(wú)需替換資源的場(chǎng)景吭狡。
-
Resources
還提供了getIdentifier
方法來(lái)獲取對(duì)應(yīng)資源 id。 - 需要適配暗藍(lán)皮膚的資源丈莺,統(tǒng)一在原資源名稱的基礎(chǔ)上加上
_blue
后綴划煮。 - 通過(guò)
Resources#getIdentifier
方法獲取對(duì)應(yīng)暗藍(lán)皮膚的資源 id。如果沒(méi)找到缔俄,改方法會(huì)返回0
弛秋。
現(xiàn)在就可以通過(guò) SkinResources
來(lái)獲取適配多皮膚的資源了器躏。但是,之前的代碼都是通過(guò) Context
直接獲取的蟹略,如果全部替換成 SkinResources
來(lái)獲取登失,那代碼改動(dòng)量就大了。
我們回到前面 Context.java
的源碼科乎,可以發(fā)現(xiàn)它獲取資源時(shí)壁畸,都是通過(guò) Context#getResources
方法先得到 Resources
對(duì)象,再通過(guò)其去獲取資源的茅茂。而 Context#getResources
方法也是可以重寫的捏萍,這意味著我們可以維護(hù)一個(gè)自己的 Resources
對(duì)象。Application
和 Activity
也都是繼承自 Context
的空闲,所以我們?cè)谄渥宇愔兄貙?getResources
方法即可:
// BaseActivity.java/BaseApplication.java
private Resources mSkinResources;
@Override
public Resources getResources() {
if (mSkinResources == null) {
mSkinResources = new SkinResources(this, super.getResources());
}
return mSkinResources;
}
到此令杈,基本邏輯就寫完了,馬上 build
跑起來(lái)碴倾。
咦逗噩,好像有點(diǎn)不太對(duì)勁,有些 color
或 drawable
沒(méi)有適配成功跌榔。
經(jīng)過(guò)一番對(duì)比异雁,發(fā)現(xiàn) xml
布局中的資源都沒(méi)有替換成功。
那么問(wèn)題在哪呢僧须?還是先從源碼著手纲刀,先來(lái)看看 View
是如何從 xml
中獲取并設(shè)置 background
屬性的:
// View.java
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);
// AttributeSet 是 xml 中所有屬性的集合
// TypeArray 則是經(jīng)過(guò)處理過(guò)的集合,將原始的 xml 屬性值("@color/colorBg")轉(zhuǎn)換為所需的類型担平,并應(yīng)用主題和樣式
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
是通過(guò) Resources#loadDrawable
方法來(lái)加載資源的示绊,而我們之前寫 SkinResources
的時(shí)候并沒(méi)有重寫該方法,為什么呢暂论?那是因?yàn)樵摲椒ㄊ潜?@UnsupportedAppUsage
標(biāo)記的面褐。所以,這就是 xml
布局中的資源替換不成功的原因取胎。
這個(gè)問(wèn)題又怎么解決呢展哭?
之前采用 Android-skin-support 方案做換膚時(shí),了解到它的原理扼菠,其會(huì)替換成自己的實(shí)現(xiàn)的 LayoutInflater.Factory2
摄杂,并在創(chuàng)建 View 時(shí)替換生成對(duì)應(yīng)適配了換膚功能的 View 對(duì)象。例如:將 View
替換成 SkinView
循榆,而 SkinView
初始化時(shí)再重新處理 background
屬性析恢,即可完成換膚。
AppCompat
也是同樣的邏輯秧饮,通過(guò) AppCompatViewInflater
將普通的 View 替換成帶 AppCompat-
前綴的 View。
其實(shí)我們只需能操作生成后的 View,并且知道 xml 中寫了哪些屬性值即可唐断。那么我們完全照搬 AppCompat
這套邏輯即可:
- 定義類繼承
LayoutInflater.Factory2
,并實(shí)現(xiàn)onCreateView
方法帽撑。 -
onCreateView
主要是創(chuàng)建 View 的邏輯,而這部分邏輯完全 copyAppCompatViewInflater
類即可鞍时。 - 在
onCreateView
中創(chuàng)建 View 之后亏拉,返回 View 之前,實(shí)現(xiàn)我們自己的邏輯逆巍。 - 通過(guò)
LayoutInflaterCompat#setFactory2
方法及塘,設(shè)置我們自己的 Factory2。
相關(guān)代碼片段:
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锐极,未找到時(shí)返回 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);
}
至此笙僚,這套方案已經(jīng)可以解決目前的換膚需求了,剩下的就是進(jìn)行細(xì)節(jié)適配了灵再。
其他說(shuō)明
自定義控件與第三方控件適配
上面只對(duì) background
屬性進(jìn)行了處理肋层,其他需要進(jìn)行換膚的屬性也是同樣的處理邏輯。如果是自定義的控件翎迁,可以在初始化時(shí)調(diào)用 TypedArray#getResourceId
方法先獲取資源 id栋猖,再通過(guò) context
去獲取對(duì)應(yīng)資源,而不是使用 TypedArray#getDrawable
類似方法直接獲取資源對(duì)象汪榔,這樣可以確保換膚成功掂铐。而第三方控件也可通過(guò) background
屬性同樣的處理邏輯進(jìn)行適配。
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
文件內(nèi)的 color
并不會(huì)完成資源替換揍异,根據(jù)上面的邏輯,需要新增以下內(nèi)容:
<!-- bg_blue.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background_blue" />
</shape>
如此爆班,資源替換才會(huì)成功衷掷。
設(shè)計(jì)的配合
這次對(duì)第三款皮膚的適配還是蠻輕松的,主要是有以下基礎(chǔ):
- 在適配暗黑主題的時(shí)候柿菩,設(shè)計(jì)有出設(shè)計(jì)規(guī)范戚嗅,后續(xù)開(kāi)發(fā)按照設(shè)計(jì)規(guī)范來(lái)。
- 暗黑和暗藍(lán)共用一套圖片資源枢舶,大大減少適配工作量懦胞。
- 暗黑和暗藍(lán)部份共用顏色值含透明度,同樣減少了工作量凉泄,僅少量顏色需要新增躏尉。
這次適配的主要工作量還是來(lái)自 <shape>
的替換。
暗藍(lán)皮膚資源文件的歸處
我知道很多換膚方案都會(huì)將皮膚資源制作成皮膚包后众,但是這個(gè)方案沒(méi)有這么做胀糜。一是沒(méi)有那么多需要替換的資源颅拦,二是為了減少相應(yīng)的工作量。
我新建了一個(gè)資源文件夾教藻,與 res
同級(jí)距帅,取名 res-blue
。并在 gradle 配置文件中配置它括堤。編譯后系統(tǒng)會(huì)自動(dòng)將它們合并碌秸,同時(shí)也能與常規(guī)資源文件隔離開(kāi)來(lái)。
// build.gradle
sourceSets {
main {
java {
srcDir 'src/main/java'
}
res.srcDirs += 'src/main/res'
res.srcDirs += 'src/main/res-blue'
}
}
有哪些坑悄窃?
WebView 資源缺失導(dǎo)致閃退
版本上線后讥电,發(fā)現(xiàn)有 android.content.res.Resources$NotFoundException
異常上報(bào),具體異常堆棧信息:
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)
經(jīng)查才發(fā)現(xiàn)在 WebView 中長(zhǎng)按文本彈出操作菜單時(shí)广匙,就會(huì)引發(fā)該異常導(dǎo)致 App 閃退允趟。
這是其他插件化方案也踩過(guò)的坑,我們只需在創(chuàng)建 SkinResources
之前將外部 WebView
的資源路徑添加進(jìn)來(lái)即可鸦致。
@Override
public Resources getResources() {
if (mSkinResources == null) {
WebViewResourceHelper.addChromeResourceIfNeeded(this);
mSkinResources = new SkinResources(this, super.getResources());
}
return mSkinResources;
}
具體問(wèn)題分析可參考
最終效果圖
總結(jié)
這個(gè)方案在原本使用系統(tǒng)方式適配暗黑主題的基礎(chǔ)上潮剪,通過(guò)攔截 Resources
相關(guān)獲取資源的方法,替換換膚后的資源 id分唾,以達(dá)到換膚的效果抗碰。針對(duì) XML 布局換膚不成功的問(wèn)題,復(fù)制 AppCompatViewInflater
創(chuàng)建 View 的代碼邏輯绽乔,并在 View 創(chuàng)建成功后重新設(shè)置需要進(jìn)行換膚的相關(guān) XML 屬性弧蝇。同一皮膚資源使用單獨(dú)的資源文件夾獨(dú)立存放,可以與正常資源進(jìn)行隔離折砸,也避免了制作皮膚包而增加工作量看疗。
目前來(lái)說(shuō)這套方案是改造成本最小,侵入性最小的選擇睦授。選擇適合自身需求的才是最好的两芳。
作者:ONEW
鏈接:https://juejin.cn/post/7187282270360141879