Android 換膚原理分析

當了解了一些知識,應該用文字記錄它,再抽個時間再看它,永遠記住它

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,兩種原理都分析完了。上面遺留的問題就是:

  1. 如何獲取需要換膚的 View
  2. 如何知道這個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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章