當了解了一些知識,應該用文字記錄它,再抽個時間再看它,永遠記住它
Android 換膚的理論知識和文章已經很多了,這裏記錄一下自己對這塊的理解。本文效果如下:
工程:一鍵換膚的快樂
一、換膚的由來
首先,爲什麼要換膚呢?那肯定是一套UI不滿足需求,無法面對多變的需求,從而需要有可以自由去更換UI 的手段,而這也是換膚想要達到的目的。
比如,一個imageview , 現在設置了一張圖片,但是 618 來了, 我先更換成新的圖片,怎麼辦?總不能讓用戶再更新一遍吧,雖然可以增量更新,但總不能每次都直接更新吧?
那我麼一般怎麼更新 imageview 的圖片呢?
ImageView imageView = findViewById(R.id.image);
imageView.setImageResource(R.mipmap.bg);
可以通過 setImageResource() 設置更新圖片。
1.1 應用內換膚分析
那如果我下發了換膚命令,怎麼更新呢?如果是應用內更新,那圖片的名字肯定是不能一樣的,不能R文件找不到;這個時候,我們可以新建一個 res_skin ,skin_bg 改個名字,比如 skin_bg。
然後在換膚命令來的時候,換成如下代碼:
imageView.setImageResource(R.mipmap.skin_bg);
那我換膚命令哪知道你有多少個 view 啊 ?怎麼知道你要替換是 mipmap ,還是 color 啊?
別急,這個後面會講。
1.2 插件換膚分析
上面是應用內換膚,如果是插件換膚呢。
插件換膚的話,就是把要替換的資源,比如上面的 bg 圖片,放到一個apk 中,然後從這個apk 中取出這個資源,插件換膚不需要給資源名稱,與原apk 保持一致即可。
怎麼取呢,從上面 R.mipmap.bg 知道 ,所有得知道資源是從 mipmap 取,且名字叫做 bg 就可以取到這個 id 了。
幸運的是,Resource 有個方法:
public int getIdentifier(String name, String defType, String defPackage) {
return mResourcesImpl.getIdentifier(name, defType, defPackage);
}
參數解釋如下:
- name:資源名稱
- defType : 資源類型,比如 mipmap,color,string…
- defPackage : 目標包名
那這樣的話,事實上,
imageView.setImageResource(R.mipmap.bg);
也可以寫成:
int res = getResources().getIdentifier("bg","mipmap",getPackageName());
if (res != 0){
imageView.setImageResource(res);
}
可以看到,確實顯示出來了:
咦,那我只要去加載皮膚的資源包,再通過 resource 的 getIdentifier 不就可以拿到資源文件了嗎,然後同通過 view 去設置就可以了。
那怎麼去解析這個 皮膚資源包呢?
我們知道 Android 的資源管理,除了 Resource ,還有 AssetManager;其中 Resource 類可以通過 ID 來查找資源,而 AssetManager 則可以根據文件名來查找資源。
那這裏就好辦了,就使用 AssetManager ,然後它有個方法:
/**
* @deprecated Use {@link #setApkAssets(ApkAssets[], boolean)}
* @hide
*/
@Deprecated
public int addAssetPath(String path) {
return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/);
}
但這個是 hide 方法,且標註爲 Deprecated,建議我們去使用setApkAssets,但 ApkAsset 又是 hide,難頂。
但筆者搜索了一下 setApkAssets 基本都是源碼在使用,而主流的換膚,插件基本還是用 addAssetPath,且在 Android P 上試了一下,也沒啥問題,所以這裏也暫時用這個把。既然是 hide ,那肯定用反射了:
try {
//拿到資源加載器
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, skinPath);
} catch (Exception e) {
LggUtils.e("SkinManager - loadSkinPath error: " + e.getMessage());
e.printStackTrace();
}
最後,還是要用 Resource去加載 id 的,所以,這裏創建的 Resource,使用 assetmanager 參數的,
Resources skinResources = new Resources(assetManager, mContext.getResources().getDisplayMetrics()
, mContext.getResources().getConfiguration());
後面咱們就可以使用 skinResource 和 getIdentifier 去加載資源了。
Ok,兩種原理都分析完了。上面遺留的問題就是:
- 如何獲取需要換膚的 View
- 如何知道這個view的換膚屬性,比如是 bitmap,還是 color等
下面一起解決這個問題。
二、View 的生成過程
從 activity 下手,一般我們都是 setContentView(R.layout.main_activity) 去設置我們的 xml,但有沒有想過,爲啥設置了這個方法之後,就能拿到 View 呢?
再拋出一個問題,比如你在 xml,寫個 textview 和 button 如下:
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="測試換膚"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="換膚"/>
然後打開 Tool - Layout Insepctor 查看:
額,怎麼我的 textview 和 button 變成了 AppCompatTextView 和 AppCompatButton 了?
帶着這個疑惑,我們從 setContentView 跟蹤下去:
@Override
public void setContentView(@LayoutRes int layoutResID) {
getDelegate().setContentView(layoutResID);
}
首先,如果你的 activity 繼承 AppCompatActivity,那麼它會通過 一個 Delegate 代理類去設置 setContentView,它是個抽象方法,它的具體實現類是AppCompatDelegateImpl,但爲了更好的看到整個過程,我建議你把targetSdkVersion改成26,然後去看 AppCompatDelegateImplV9,原理都是一樣的,這是更加清晰。
好了,題外話過,去到實現類的 setContentView,可以看到:
除了我們熟悉的 R.id.content,最重要的就是 LayoutInflater 的 inflate 方法了,進入看看:
可以看到,拿到了 resource 之後,通過 res.getLayout(resource) 去解析 xml 佈局,最後繼續執行 inflate 方法,繼續看下去:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");
final Context inflaterContext = mContext;
final AttributeSet attrs = Xml.asAttributeSet(parser);
Context lastContext = (Context) mConstructorArgs[0];
mConstructorArgs[0] = inflaterContext;
View result = root;
...
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
.....
return result;
}
}
這個方法會先去解析是否有自定義屬性,然後可以從 xml 文件根部去解析;最重要的是裏面有個方法 createViewFromTag,它是 view 生成的關鍵點,進入看看:
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
....
try {
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);
}
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
...
重點可以看到,View 的解析首先,會先判斷 mFactory2 是否不爲null,如果不是,則去通過 onCreateView 去創建這個 view,如果爲 null,則判斷 mFactory (其實如果你設置 mFactory ,到源碼裏面還是被替換成 mFactory2 的,具體自己跟蹤),以此類推;
等等, 這個 mFactory2 哪來的?跟蹤的時候沒看到啊?
別急,當你繼承 AppCompatActivity 的時候,我們進入看看
在 oncreate 方法的時候,有個 installViewFactory()方法,它的具體實現類是 AppCompatDelegateImpl ,可以看到:
恩恩,這個就好說了。
接着如果都找不到這個 view,則會通過 createView 這個方法去重新解析 View。去到 mFactory2 中的 onCreateView 方法,你是一個接口,具體實現類是 AppCompatDelegateImpl 或 AppCompatDelegateImplV9 (targetSdkVersion 26),看看裏面的方法:
裏面會把它再交給 mAppCompatViewInflater.createView(),然後可以看到:
public final View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs, boolean inheritContext,
boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
final Context originalContext = context;
...
View view = null;
// We need to 'inject' our tint aware Views in place of the standard framework versions
switch (name) {
case "TextView":
view = new (context, attrs);
break;
case "ImageView":
view = new AppCompatImageView(context, attrs);
break;
case "Button":
view = new AppCompatButton(context, attrs);
break;
case "EditText":
view = new AppCompatEditText(context, attrs);
break;
case "Spinner":
view = new AppCompatSpinner(context, attrs);
break;
case "ImageButton":
view = new AppCompatImageButton(context, attrs);
break;
case "CheckBox":
view = new AppCompatCheckBox(context, attrs);
break;
case "RadioButton":
view = new AppCompatRadioButton(context, attrs);
break;
case "CheckedTextView":
view = new AppCompatCheckedTextView(context, attrs);
break;
case "AutoCompleteTextView":
view = new AppCompatAutoCompleteTextView(context, attrs);
break;
case "MultiAutoCompleteTextView":
view = new AppCompatMultiAutoCompleteTextView(context, attrs);
break;
case "RatingBar":
view = new AppCompatRatingBar(context, attrs);
break;
case "SeekBar":
view = new AppCompatSeekBar(context, attrs);
break;
}
if (view == null && originalContext != context) {
// If the original context does not equal our themed context, then we need to manually
// inflate it using the name so that android:theme takes effect.
view = createViewFromTag(context, name, attrs);
}
if (view != null) {
// If we have created a view, check its android:onClick
checkOnClickListener(view, attrs);
}
return view;
}
真香最終打敗了,原來如果 activity 繼承 AppCompatActivity,則在內部,會把 textView 替換成 AppCompatTextView,這也是我們在 xml 中寫 TextView,在 layout inspector 卻顯示 AppCompatTextView 的問題了。
當然,不是每個 view 都替換,如果找不到這個 view,則通過 createViewFromTag(context, name, attrs); 去解析:
可以發現,還是用了 createView 去解析,createView方法時通過 類加載去加載的,這裏不深入瞭解了。
2.1 簡單替換 View
從上面知道了,View 的生成在 mFactory2 中的 onCreateView 中,那麼,這裏,我們做個小實驗,比如檢測到 textview ,把它改成 button 試試,由於 AppCompatActivity 在 onCreate 之前就設置了 mFactory2,所以,我們自己的 factory 要放到 super.oncreate() 之前,如下:
@Override
protected void onCreate(Bundle savedInstanceState) {
LayoutInflaterCompat.setFactory2(LayoutInflater.from(this), new LayoutInflater.Factory2() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (name.equals("TextView") ){
Button button = new Button(context);
button.setText("我被替換了");
return button;
}
return null;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
//這個方法時 mFactory,因爲 mFactory2 繼承 mFactory ,所以可以不用管
return null;
}
});
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
好了,看一下效果,換膚前:
換膚後:
可以看到,TextView 確實被替換了,不過我們看一下 layout insepctor:
咦,我的 Button 沒有替換成 AppCompatButton了,爲啥呢?
因爲我們自己設置了 factory ,且在 onCreateView 回調的時候,直接返回 button了:
都沒經過 系統的替換,那這裏肯定沒變了。那我想享受 AppCompat 帶來的額外屬性怎麼辦?
簡單,我們自己不去創建 View,交還給系統去創建,把 name 改成 button 就可以了,如下:
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (name.equals("TextView") ){
name = "Button";
}
View view = getDelegate().createView(parent,name,context,attrs);
return view;
}
再看看 layout inspector:
三、實際應用
通過上面分析,你應該知道 factory 的作用,常見的實際應用有以下:
3.1 全局替換字體
有時候需要一鍵該字體,那我們檢測到當前view 爲 textview,全局替換即可,簡單代碼如下:
final Typeface typeface = Typeface.createFromAsset(getAssets(),"yahei.ttf");
LayoutInflaterCompat.setFactory2(LayoutInflater.from(this), new LayoutInflater.Factory2() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = getDelegate().createView(parent,name,context,attrs);
if (view instanceof TextView){
TextView textView = (TextView) view;
textView.setTypeface(typeface);
}
return view;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
//這個方法時 mFactory,因爲 mFactory2 繼承 mFactory ,所以可以不用管
return null;
}
});
3.2 換膚
這個網上都很成熟的方法了,但自己搞一個不香嗎;可以應用上面的知識嘗試一下;
很多網上的換膚框架都需要繼承 baseActivity,baseFragment 的,又或者說什麼需要傳遞 context 的,比如 skinManager.with(this)。
額,其實這裏有小技巧,其實我們在自己的庫裏,編寫一個 contentprovider,從 onCreate 拿到 context,檢測到這個 context 是application,就可以通過 application 去拿到所有的 activity 了。比如:
然後在 onActivityCreated 的時候,添加我們的皮膚注入即可,如下:
感興趣可以看看這個:https://github.com/LillteZheng/ZSkinPlugin
效果如下:
3.3無需編寫shape、selector,直接在xml設置值
前段時間火到爆的,原理也是用到 factory,上面的 contentprovider 小技巧也是參考這個的哦;
地址: https://juejin.im/post/5b9682ebe51d450e543e3495
這樣,這篇文章就寫完了。
參考:https://mp.weixin.qq.com/s/1ua0geFnrbQbyHi8KG2VJQ