布景说明
原本已经根据体系计划适配了暗黑主题,完结了白/黑两套皮肤,以及跟从体系。后来老板研究学习友商时,发现友商 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#getColor
和 Resources#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
目标。Application
和 Activity
也都是承继自 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
跑起来。
咦,如同有点不太对劲,有些 color
或 drawable
没有适配成功。
经过一番对比,发现 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 的逻辑,而这部分逻辑彻底 copyAppCompatViewInflater
类即可。 - 在
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)
终究作用图
总结
这个计划在原本运用体系方式适配暗黑主题的根底上,经过拦截 Resources
相关获取资源的办法,替换换肤后的资源 id,以到达换肤的作用。针对 XML 布局换肤不成功的问题,复制 AppCompatViewInflater
创立 View 的代码逻辑,并在 View 创立成功后重新设置需求进行换肤的相关 XML 特点。同一皮肤资源运用单独的资源文件夹独立存放,能够与正常资源进行隔离,也避免了制作皮肤包而增加工作量。
现在来说这套计划是改造成本最小,侵入性最小的选择。选择适合本身需求的才是最好的。