一、換膚方案
目前,市面上Android的換膚方案主要有Resource方案和AssetManager替換方案兩種方案。
其中,Resource方案是使用者提前自定義一些主題,然後將指定主題對應的 id 設定成預設的主題即可。而AssetManager替換方案,使用的是Hook系統AssetMananger物件,然後再編譯期靜態對齊資原始檔對應的id數值。
1.1 Resource方案
Resource方案的原理大概如下:
1、建立新的Resrouce物件(代理的Resource)
2、替換系統Resource物件
3、執行時動態對映(原理相同資源在不同的資源表中的Type和Name一樣)
4、xml佈局解析攔截(xml佈局中的資源不能透過代理Resource載入,LayoutInflater)
此方案的優勢是支援String/Layout的替換,不過缺點也很明顯:
- 資源獲取效率有影響
- 不支援style、asset目錄
Resource多出替換,Resource包裝類程式碼量大
1.2 AssetManager方案
使用的是Hook系統AssetMananger物件,然後再編譯期靜態對齊資原始檔對應的id數值,達到替換資源的目的。此種方案,最常見的就是Hook LayoutInflater進行換膚。
二、Resource換膚
此種方式採用的方案是:使用者提前自定義一些主題,然後當設定主題的時候將指定主題對應的 id 記錄到本地檔案中,當 Activity RESUME 的時候,判斷 Activity 當前的主題是否和之前設定的主題一致,不一致的話就呼叫當前 Activity 的recreate()方法進行重建。
比如,在這種方案中,我們可以透過如下的方式預定義一些屬性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="themed_divider_color" format="color"/>
<attr name="themed_foreground" format="color"/>
<!-- .... -->
</resources>
然後,在自定義主題中使用為這些預定義屬性賦值。
<style name="Base.AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="themed_foreground">@color/warm_theme_foreground</item>
<item name="themed_background">@color/warm_theme_background</item>
<!-- ... -->
</style>
最後,在佈局檔案中透過如下的方式引用這些自定義屬性。
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv"
android:textColor="?attr/themed_text_color_secondary"
... />
<View android:background="?attr/themed_divider_color"
android:layout_width="match_parent"
android:layout_height="1px"/>
三、Hook LayoutInflater方案
3.1 工作原理
透過 Hook LayoutInflater 進行換膚的方案是眾多開源方案中比較常見的一種。在分析這種方案之前,我們最好先了解下 LayoutInflater 的工作原理。通常,當我們想要自定義 Layout 的 Factory 的時候可以呼叫下面兩個方法將我們的 Factory 設定到系統的 LayoutInflater 中。
public abstract class LayoutInflater {
public void setFactory(Factory factory) {
if (mFactorySet) throw new IllegalStateException("A factory has already been set on this LayoutInflater");
if (factory == null) throw new NullPointerException("Given factory can not be null");
mFactorySet = true;
if (mFactory == null) {
mFactory = factory;
} else {
mFactory = new FactoryMerger(factory, null, mFactory, mFactory2);
}
}
public void setFactory2(Factory2 factory) {
if (mFactorySet) throw new IllegalStateException("A factory has already been set on this LayoutInflater");
if (factory == null) throw new NullPointerException("Given factory can not be null");
mFactorySet = true;
if (mFactory == null) {
mFactory = mFactory2 = factory;
} else {
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}
}
當我們呼叫 inflator()方法從 xml 中載入佈局的時候,將會走到如下程式碼真正執行載入操作。
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
// ....
final Context inflaterContext = mContext;
final AttributeSet attrs = Xml.asAttributeSet(parser);
Context lastContext = (Context) mConstructorArgs[0];
mConstructorArgs[0] = inflaterContext;
View result = root;
try {
advanceToRootNode(parser);
final String name = parser.getName();
// 處理 merge 標籤
if (TAG_MERGE.equals(name)) {
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// 從 xml 中載入佈局控制元件
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
// 生成佈局引數 LayoutParams
ViewGroup.LayoutParams params = null;
if (root != null) {
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
temp.setLayoutParams(params);
}
}
// 載入子控制元件
rInflateChildren(parser, temp, attrs, true);
// 新增到根控制元件
if (root != null && attachToRoot) {
root.addView(temp, params);
}
if (root == null || !attachToRoot) {
result = temp;
}
}
} catch (XmlPullParserException e) {/*...*/}
return result;
}
}
接下來,我們看一下createViewFromTag()方法。
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) {
// 老的佈局方式
if (name.equals("view")) {
name = attrs.getAttributeValue(null, "class");
}
// 處理 theme
if (!ignoreThemeAttr) {
final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
final int themeResId = ta.getResourceId(0, 0);
if (themeResId != 0) {
context = new ContextThemeWrapper(context, themeResId);
}
ta.recycle();
}
try {
View view = tryCreateView(parent, name, context, attrs);
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(context, parent, name, attrs);
} else {
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (InflateException e) {
// ...
}
}
public final View tryCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (name.equals(TAG_1995)) {
return new BlinkLayout(context, attrs);
}
// 優先使用 mFactory2 建立 view,mFactory2 為空則使用 mFactory,否則使用 mPrivateFactory
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
return view;
}
可以看出,這裡優先使用 mFactory2 建立 view,mFactory2 為空則使用 mFactory,否則使用 mPrivateFactory 載入 view。所以,如果我們想要對 view 建立過程進行 hook,就應該 hook 這裡的 mFactory2,因為它的優先順序最高。
注意到這裡的 方法中並沒有迴圈,所以,第一次的時候只能載入根佈局。那麼根佈局內的子控制元件是如何載入的呢?這就用到了inflaterInflateChildren()方法。
final void rInflateChildren(XmlPullParser parser, View parent, AttributeSet attrs,
boolean finishInflate) throws XmlPullParserException, IOException {
rInflate(parser, parent, parent.getContext(), attrs, finishInflate);
}
void rInflate(XmlPullParser parser, View parent, Context context, AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
final int depth = parser.getDepth();
int type;
boolean pendingRequestFocus = false;
while (((type = parser.next()) != XmlPullParser.END_TAG || parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
if (type != XmlPullParser.START_TAG) continue;
final String name = parser.getName();
if (TAG_REQUEST_FOCUS.equals(name)) {
// 處理 requestFocus 標籤
pendingRequestFocus = true;
consumeChildElements(parser);
} else if (TAG_TAG.equals(name)) {
// 處理 tag 標籤
parseViewTag(parser, parent, attrs);
} else if (TAG_INCLUDE.equals(name)) {
// 處理 include 標籤
if (parser.getDepth() == 0) {
throw new InflateException("<include /> cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
} else if (TAG_MERGE.equals(name)) {
// 處理 merge 標籤
throw new InflateException("<merge /> must be the root element");
} else {
// 這裡處理的是普通的 view 標籤
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
// 繼續處理子控制元件
rInflateChildren(parser, view, attrs, true);
viewGroup.addView(view, params);
}
}
if (pendingRequestFocus) {
parent.restoreDefaultFocus();
}
if (finishInflate) {
parent.onFinishInflate();
}
}
注意到該方法內部又呼叫了createViewFromTag和rInflateChildren方法,也就是說,這裡透過遞迴的方式實現對整個 view 樹的遍歷,從而將整個 xml 載入為 view 樹。以上是安卓的 LayoutInflater 從 xml 中載入控制元件的邏輯,可以看出我們可以透過 hook 實現對建立 view 的過程的“監聽”。
上面我們說了下換膚的原理,下面我們介紹幾種Android換膚的技術框架:Android-Skin-Loader、ThemeSkinning和Android-skin-support。
3.2 Android-Skin-Loader
3.2.1 使用流程
學習了 Hook LayoutInflator 的底層原理之後,我們來看幾個基於這種原理實現的換膚方案。首先是 Android-Skin-Loader 這個庫,這個庫需要你覆寫Activity,然後再替換皮膚,Activity部分程式碼如下。
public class BaseActivity extends Activity implements ISkinUpdate, IDynamicNewView{
private SkinInflaterFactory mSkinInflaterFactory;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mSkinInflaterFactory = new SkinInflaterFactory();
getLayoutInflater().setFactory(mSkinInflaterFactory);
}
// ...
}
可以看出,這裡將自定義的 Factory 設定給了LayoutInflator,SkinInflaterFactory的實現如下:
public class SkinInflaterFactory implements Factory {
private static final boolean DEBUG = true;
private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
// 讀取自定義屬性 enable,這裡用了自定義的 namespace
boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
if (!isSkinEnable){
return null;
}
// 建立 view
View view = createView(context, name, attrs);
if (view == null){
return null;
}
parseSkinAttr(context, attrs, view);
return view;
}
private View createView(Context context, String name, AttributeSet attrs) {
View view = null;
try {
// 相容低版本建立 view 的邏輯(低版本是沒有完整包名)
if (-1 == name.indexOf('.')){
if ("View".equals(name)) {
view = LayoutInflater.from(context).createView(name, "android.view.", attrs);
}
if (view == null) {
view = LayoutInflater.from(context).createView(name, "android.widget.", attrs);
}
if (view == null) {
view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs);
}
} else {
// 新的建立 view 的邏輯
view = LayoutInflater.from(context).createView(name, null, attrs);
}
} catch (Exception e) {
view = null;
}
return view;
}
private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();
// 對 xml 中控制元件的屬性進行解析
for (int i = 0; i < attrs.getAttributeCount(); i++){
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
// 判斷屬性是否支援,屬性是預定義的
if(!AttrFactory.isSupportedAttr(attrName)){
continue;
}
// 如果是引用型別的屬性值
if(attrValue.startsWith("@")){
try {
int id = Integer.parseInt(attrValue.substring(1));
String entryName = context.getResources().getResourceEntryName(id);
String typeName = context.getResources().getResourceTypeName(id);
// 加入屬性列表
SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
if (mSkinAttr != null) {
viewAttrs.add(mSkinAttr);
}
} catch (NumberFormatException e) {/*...*/}
}
}
if(!ListUtils.isEmpty(viewAttrs)){
// 構建該控制元件的屬性關係
SkinItem skinItem = new SkinItem();
skinItem.view = view;
skinItem.attrs = viewAttrs;
mSkinItems.add(skinItem);
if(SkinManager.getInstance().isExternalSkin()){
skinItem.apply();
}
}
}
}
這裡自定義了一個 xml 屬性,用來指定是否啟用換膚配置。然後在建立 view 的過程中解析 xml 中定義的 view 的屬性資訊,比如,background 和 textColor 等屬性。並將其對應的屬性、屬性值和控制元件以對映的形式記錄到快取中。當發生換膚的時候根據這裡的對映關係在程式碼中更新控制元件的屬性資訊。
public class BackgroundAttr extends SkinAttr {
@Override
public void apply(View view) {
if(RES_TYPE_NAME_COLOR.equals(attrValueTypeName)){
// 注意這裡獲取屬性值的時候是透過 SkinManager 的方法獲取的
view.setBackgroundColor(SkinManager.getInstance().getColor(attrValueRefId));
}else if(RES_TYPE_NAME_DRAWABLE.equals(attrValueTypeName)){
Drawable bg = SkinManager.getInstance().getDrawable(attrValueRefId);
view.setBackground(bg);
}
}
}
如果是動態新增的 view,比如在 java 程式碼中,該庫提供了 等方法來動態新增對映關係到快取中。在 activity 的生命週期方法中註冊監聽換膚事件:
public class BaseActivity extends Activity implements ISkinUpdate, IDynamicNewView{
@Override
protected void onResume() {
super.onResume();
SkinManager.getInstance().attach(this);
}
@Override
protected void onDestroy() {
super.onDestroy();
SkinManager.getInstance().detach(this);
// 清理快取資料
mSkinInflaterFactory.clean();
}
@Override
public void onThemeUpdate() {
if(!isResponseOnSkinChanging){
return;
}
mSkinInflaterFactory.applySkin();
}
// ...
}
當換膚的時候會通知到 Activity 並觸發onThemeUpdate方法,接著呼叫 SkinInflaterFactory 的 apply 方法。SkinInflaterFactory 的 apply 方法中對快取的屬性資訊遍歷更新實現換膚。
3.2.2 皮膚包載入邏輯
接下來,我們看一下皮膚包的載入邏輯,即透過自定義的 AssetManager 實現,類似於外掛化。
public void load(String skinPackagePath, final ILoaderListener callback) {
new AsyncTask<String, Void, Resources>() {
protected void onPreExecute() {
if (callback != null) {
callback.onStart();
}
};
@Override
protected Resources doInBackground(String... params) {
try {
if (params.length == 1) {
String skinPkgPath = params[0];
File file = new File(skinPkgPath);
if(file == null || !file.exists()){
return null;
}
PackageManager mPm = context.getPackageManager();
PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
skinPackageName = mInfo.packageName;
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);
Resources superRes = context.getResources();
Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());
SkinConfig.saveSkinPath(context, skinPkgPath);
skinPath = skinPkgPath;
isDefaultSkin = false;
return skinResource;
}
return null;
} catch (Exception e) { /*...*/ }
};
protected void onPostExecute(Resources result) {
mResources = result;
if (mResources != null) {
if (callback != null) callback.onSuccess();
notifySkinUpdate();
}else{
isDefaultSkin = true;
if (callback != null) callback.onFailed();
}
};
}.execute(skinPackagePath);
}
然後,在獲取值的時候使用下面的方法:
public int getColor(int resId){
int originColor = context.getResources().getColor(resId);
if(mResources == null || isDefaultSkin){
return originColor;
}
String resName = context.getResources().getResourceEntryName(resId);
int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
int trueColor = 0;
try{
trueColor = mResources.getColor(trueResId);
}catch(NotFoundException e){
e.printStackTrace();
trueColor = originColor;
}
return trueColor;
}
3.2.3 方案特點
此種方案換膚,有如下的一些特點:
- 換膚需要繼承自定義 activity
- 皮膚包和 APK 如果使用了資源混淆載入的時候就會出現問題
- 沒處理屬性值透過 的形式引用的情況?attr
- 每個換膚的屬性需要自己註冊並實現
- 有些控制元件的一些屬性可能沒有提供對應的 java 方法,因此在程式碼中換膚就行不通
- 沒有處理使用 style 的情況
- 基於 實現,版本太老android.app.Activity
在 inflator 建立 view 的時候,其實只做了對屬性的攔截處理操作,可以透過代理系統的 Factory 實現建立 view 的操作
3.3 ThemeSkinning
這個庫是基於上面的 Android-Skin-Loader 開發的,在其基礎之上做了許多的調整,其地址是 ThemeSkinning。主要調整的內容如下:
3.3.1 AppCompactActivity調整
該庫基於 AppCompactActivity 和LayoutInflaterCompat.setFactory開發,改動的內容如下:
public class SkinBaseActivity extends AppCompatActivity implements ISkinUpdate, IDynamicNewView {
private SkinInflaterFactory mSkinInflaterFactory;
private final static String TAG = "SkinBaseActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
mSkinInflaterFactory = new SkinInflaterFactory(this);
LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
super.onCreate(savedInstanceState);
changeStatusColor();
}
// ...
}
同時,該庫也提供了修改狀態列的方法,雖然能力比較有限。
3.3.2 SkinInflaterFactory調整
SkinInflaterFactory對建立View做了一些調整,程式碼如下:
public class SkinInflaterFactory implements LayoutInflater.Factory2 {
private Map<View, SkinItem> mSkinItemMap = new HashMap<>();
private AppCompatActivity mAppCompatActivity;
public SkinInflaterFactory(AppCompatActivity appCompatActivity) {
this.mAppCompatActivity = appCompatActivity;
}
@Override
public View onCreateView(String s, Context context, AttributeSet attributeSet) {
return null;
}
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
// 沿用之前的一些邏輯
boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
AppCompatDelegate delegate = mAppCompatActivity.getDelegate();
View view = delegate.createView(parent, name, context, attrs);
// 對字型相容做了支援,這裡是透過靜態方式將其快取到記憶體,動態新增和移除,載入字型之後呼叫 textview 的 settypeface 方法替換
if (view instanceof TextView && SkinConfig.isCanChangeFont()) {
TextViewRepository.add(mAppCompatActivity, (TextView) view);
}
if (isSkinEnable || SkinConfig.isGlobalSkinApply()) {
if (view == null) {
// 建立 view 的邏輯做了調整
view = ViewProducer.createViewFromTag(context, name, attrs);
}
if (view == null) {
return null;
}
parseSkinAttr(context, attrs, view);
}
return view;
}
// ...
}
以下是View的建立邏輯的相關程式碼:
class ViewProducer {
private static final Object[] mConstructorArgs = new Object[2];
private static final Map<String, Constructor<? extends View>> sConstructorMap = new ArrayMap<>();
private static final Class<?>[] sConstructorSignature = new Class[]{Context.class, AttributeSet.class};
private static final String[] sClassPrefixList = {"android.widget.", "android.view.", "android.webkit."};
static View createViewFromTag(Context context, String name, AttributeSet attrs) {
if (name.equals("view")) {
name = attrs.getAttributeValue(null, "class");
}
try {
// 構造引數,快取,複用
mConstructorArgs[0] = context;
mConstructorArgs[1] = attrs;
if (-1 == name.indexOf('.')) {
for (int i = 0; i < sClassPrefixList.length; i++) {
final View view = createView(context, name, sClassPrefixList[i]);
if (view != null) {
return view;
}
}
return null;
} else {
// 透過構造方法建立 view
return createView(context, name, null);
}
} catch (Exception e) {
return null;
} finally {
mConstructorArgs[0] = null;
mConstructorArgs[1] = null;
}
}
// ...
}
3.3.3 對style的相容處理
private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
List<SkinAttr> viewAttrs = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
if ("style".equals(attrName)) {
// 對 style 的處理,從 theme 中獲取 TypedArray 然後獲取 resource id,再獲取對應的資訊
int[] skinAttrs = new int[]{android.R.attr.textColor, android.R.attr.background};
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, skinAttrs, 0, 0);
int textColorId = a.getResourceId(0, -1);
int backgroundId = a.getResourceId(1, -1);
if (textColorId != -1) {
String entryName = context.getResources().getResourceEntryName(textColorId);
String typeName = context.getResources().getResourceTypeName(textColorId);
SkinAttr skinAttr = AttrFactory.get("textColor", textColorId, entryName, typeName);
if (skinAttr != null) {
viewAttrs.add(skinAttr);
}
}
if (backgroundId != -1) {
String entryName = context.getResources().getResourceEntryName(backgroundId);
String typeName = context.getResources().getResourceTypeName(backgroundId);
SkinAttr skinAttr = AttrFactory.get("background", backgroundId, entryName, typeName);
if (skinAttr != null) {
viewAttrs.add(skinAttr);
}
}
a.recycle();
continue;
}
if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
// 老邏輯
try {
//resource id
int id = Integer.parseInt(attrValue.substring(1));
if (id == 0) continue;
String entryName = context.getResources().getResourceEntryName(id);
String typeName = context.getResources().getResourceTypeName(id);
SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
if (mSkinAttr != null) {
viewAttrs.add(mSkinAttr);
}
} catch (NumberFormatException e) { /*...*/ }
}
}
if (!SkinListUtils.isEmpty(viewAttrs)) {
SkinItem skinItem = new SkinItem();
skinItem.view = view;
skinItem.attrs = viewAttrs;
mSkinItemMap.put(skinItem.view, skinItem);
if (SkinManager.getInstance().isExternalSkin() ||
SkinManager.getInstance().isNightMode()) {//如果當前皮膚來自於外部或者是處於夜間模式
skinItem.apply();
}
}
}
3.3.4 fragment 調整
在 Fragment 的生命週期方法結束的時候從快取當中移除指定的 View。
@Override
public void onDestroyView() {
removeAllView(getView());
super.onDestroyView();
}
protected void removeAllView(View v) {
if (v instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) v;
for (int i = 0; i < viewGroup.getChildCount(); i++) {
removeAllView(viewGroup.getChildAt(i));
}
removeViewInSkinInflaterFactory(v);
} else {
removeViewInSkinInflaterFactory(v);
}
}
這種方案相對第一個框架改進了很多,但是此庫已經有4,5年沒有維護了,元件和程式碼都比較老。
3.4 Android-skin-support
接下來,我們再看一下Android-skin-support 。主要修改的部分如下:
3.4.1 自動註冊 layoutinflator.factory
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
private SkinActivityLifecycle(Application application) {
application.registerActivityLifecycleCallbacks(this);
installLayoutFactory(application);
// 註冊監聽
SkinCompatManager.getInstance().addObserver(getObserver(application));
}
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
if (isContextSkinEnable(activity)) {
installLayoutFactory(activity);
// 更新 acitvity 的視窗的背景
updateWindowBackground(activity);
// 觸發換膚...如果 view 沒有建立是不是就容易導致 NPE?
if (activity instanceof SkinCompatSupportable) {
((SkinCompatSupportable) activity).applySkin();
}
}
}
private void installLayoutFactory(Context context) {
try {
LayoutInflater layoutInflater = LayoutInflater.from(context);
LayoutInflaterCompat.setFactory2(layoutInflater, getSkinDelegate(context));
} catch (Throwable e) { /* ... */ }
}
// 獲取 LayoutInflater.Factory2,這裡加了一層快取
private SkinCompatDelegate getSkinDelegate(Context context) {
if (mSkinDelegateMap == null) {
mSkinDelegateMap = new WeakHashMap<>();
}
SkinCompatDelegate mSkinDelegate = mSkinDelegateMap.get(context);
if (mSkinDelegate == null) {
mSkinDelegate = SkinCompatDelegate.create(context);
mSkinDelegateMap.put(context, mSkinDelegate);
}
return mSkinDelegate;
}
// ...
}
LayoutInflaterCompat.setFactory2()方法原始碼如下:
public final class LayoutInflaterCompat {
public static void setFactory2(LayoutInflater inflater, LayoutInflater.Factory2 factory) {
inflater.setFactory2(factory);
if (Build.VERSION.SDK_INT < 21) {
final LayoutInflater.Factory f = inflater.getFactory();
if (f instanceof LayoutInflater.Factory2) {
forceSetFactory2(inflater, (LayoutInflater.Factory2) f);
} else {
forceSetFactory2(inflater, factory);
}
}
}
// 透過反射的方式直接修改 mFactory2 欄位
private static void forceSetFactory2(LayoutInflater inflater, LayoutInflater.Factory2 factory) {
if (!sCheckedField) {
try {
sLayoutInflaterFactory2Field = LayoutInflater.class.getDeclaredField("mFactory2");
sLayoutInflaterFactory2Field.setAccessible(true);
} catch (NoSuchFieldException e) { /* ... */ }
sCheckedField = true;
}
if (sLayoutInflaterFactory2Field != null) {
try {
sLayoutInflaterFactory2Field.set(inflater, factory);
} catch (IllegalAccessException e) { /* ... */ }
}
}
// ...
}
3.4.2 LayoutInflater.Factory2
public class SkinCompatDelegate implements LayoutInflater.Factory2 {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = createView(parent, name, context, attrs);
if (view == null) return null;
// 加入快取
if (view instanceof SkinCompatSupportable) {
mSkinHelpers.add(new WeakReference<>((SkinCompatSupportable) view));
}
return view;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
View view = createView(null, name, context, attrs);
if (view == null) return null;
// 加入快取,繼承這個介面的主要是 view 和 activity 這些
if (view instanceof SkinCompatSupportable) {
mSkinHelpers.add(new WeakReference<>((SkinCompatSupportable) view));
}
return view;
}
public View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) {
// view 生成邏輯被包裝成了 SkinCompatViewInflater
if (mSkinCompatViewInflater == null) {
mSkinCompatViewInflater = new SkinCompatViewInflater();
}
List<SkinWrapper> wrapperList = SkinCompatManager.getInstance().getWrappers();
for (SkinWrapper wrapper : wrapperList) {
Context wrappedContext = wrapper.wrapContext(mContext, parent, attrs);
if (wrappedContext != null) {
context = wrappedContext;
}
}
//
return mSkinCompatViewInflater.createView(parent, name, context, attrs);
}
// ...
}
3.4.3 SkinCompatViewInflater
上述方法中 SkinCompatViewInflater 獲取 view 的邏輯如下。
public final View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) {
// 透過 inflator 建立 view
View view = createViewFromHackInflater(context, name, attrs);
if (view == null) {
view = createViewFromInflater(context, name, attrs);
}
// 根據 view 標籤建立 view
if (view == null) {
view = createViewFromTag(context, name, attrs);
}
// 處理 xml 中設定的點選事件
if (view != null) {
checkOnClickListener(view, attrs);
}
return view;
}
private View createViewFromHackInflater(Context context, String name, AttributeSet attrs) {
View view = null;
for (SkinLayoutInflater inflater : SkinCompatManager.getInstance().getHookInflaters()) {
view = inflater.createView(context, name, attrs);
if (view == null) {
continue;
} else {
break;
}
}
return view;
}
private View createViewFromInflater(Context context, String name, AttributeSet attrs) {
View view = null;
for (SkinLayoutInflater inflater : SkinCompatManager.getInstance().getInflaters()) {
view = inflater.createView(context, name, attrs);
if (view == null) {
continue;
} else {
break;
}
}
return view;
}
public View createViewFromTag(Context context, String name, AttributeSet attrs) {
// <view class="xxxx"> 形式的 tag,和 <xxxx> 一樣
if ("view".equals(name)) {
name = attrs.getAttributeValue(null, "class");
}
try {
// 構造引數快取
mConstructorArgs[0] = context;
mConstructorArgs[1] = attrs;
if (-1 == name.indexOf('.')) {
for (int i = 0; i < sClassPrefixList.length; i++) {
// 透過構造方法建立 view
final View view = createView(context, name, sClassPrefixList[i]);
if (view != null) {
return view;
}
}
return null;
} else {
return createView(context, name, null);
}
} catch (Exception e) {
return null;
} finally {
mConstructorArgs[0] = null;
mConstructorArgs[1] = null;
}
}
這裡用來建立檢視 的充氣器 是透過 獲取的。這樣設計的目的在於暴露介面給呼叫者,用來自定義控制元件的充氣器 邏輯。比如,針對三方控制元件和自定義控制元件的邏輯等。SkinCompatManager.getInstance().getInflaters()
該庫自帶的一個實現是,
public class SkinAppCompatViewInflater implements SkinLayoutInflater, SkinWrapper {
@Override
public View createView(Context context, String name, AttributeSet attrs) {
View view = createViewFromFV(context, name, attrs);
if (view == null) {
view = createViewFromV7(context, name, attrs);
}
return view;
}
private View createViewFromFV(Context context, String name, AttributeSet attrs) {
View view = null;
if (name.contains(".")) {
return null;
}
switch (name) {
case "View":
view = new SkinCompatView(context, attrs);
break;
case "LinearLayout":
view = new SkinCompatLinearLayout(context, attrs);
break;
// ... 其他控制元件的實現邏輯
}
}
// ...
}
四、其他方案
除了上面介紹的方案外,還有如下的一些方案:
4.1 TG換膚方案
TG 的換膚只支援夜間和日間主題之間的切換,所以,相對上面幾種方案 TG 的換膚就簡單得多。
在閱讀 TG 的程式碼的時候,我也 TG 在做頁面佈局的時候做了一件很瘋狂的事情——他們沒有使用任何 xml 佈局,所有佈局都是透過 java 程式碼實現的。
為了支援對主題的自定義 TG 把專案內幾乎所有的顏色分別定義了一個名稱,對以文字形式記錄到一個檔案中,數量非常多,然後將其放到 assets 下面,應用內透過讀取這個資原始檔來獲取各個控制元件的顏色。
4.2 自定義控制元件 + 全域性廣播實現換膚
這種方案根前面 hook LayoutInflator 的自動替換檢視 的方案差不多。不過,這種方案不需要做 hook,而是對應用的內常用的控制元件全部做一邊自定義。自定義控制元件內部監聽換膚的事件。當自定義控制元件接收到換膚事件的時候,自定義控制元件內部觸發換膚邏輯。不過這種換膚的方案相對於上述透過 hook LayoutInflator 的方案而言,可控性更好一些。