手寫動態(tài)換膚

前言:

換膚,目前包括靜態(tài)換膚和動態(tài)換膚
靜態(tài)換膚

這種換膚的方式,也就是我們所說的內(nèi)置換膚,就是在APP內(nèi)部放置多套相同的資源。進行資源的切換。

這種換膚的方式有很多缺點,比如, 靈活性差,只能更換內(nèi)置的資源、apk體積太大,在我們的應用Apk中等一般圖片文件能占到apk大小的一半左右。

當然了,這種方式也并不是一無是處, 比如我們的應用內(nèi),只是普通的 日夜間模式 的切換,并不需要圖片等的更換,只是更換顏色,那這樣的方式就很實用。
動態(tài)換膚

適用于大量皮膚,用戶選擇下載,像QQ、網(wǎng)易云音樂這種。它是將皮膚包下載到本地,皮膚包其實是個APK。

換膚包括替換圖片資源、布局顏色、字體、文字顏色、狀態(tài)欄和導航欄顏色。

動態(tài)換膚步驟包括:

1、采集需要換膚的控件
2、 加載皮膚包
3、 替換資源
鏈接:http://www.itdecent.cn/p/eebb8eae5ea1

按照步驟我們試著實現(xiàn)一下動態(tài)換膚的效果

1、采集需要的換膚控件,比如(android.widget.TextView,android.widget.ImageView)

通過采集支持換膚的控件以及屬性,然后保存到集合中,待遍歷替換
那么怎么采集控件呢?

我們可以看下setContentView(int id)這個指定布局的方法

  @Override
    public void setContentView(int resId) {
        ensureSubDecor();
        ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
        contentParent.removeAllViews();
        LayoutInflater.from(mContext).inflate(resId, contentParent);//這里實現(xiàn)view布局的加載
        mOriginalWindowCallback.onContentChanged();
    }
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
        return inflate(resource, root, root != null);
    }
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();
        if (DEBUG) {
            Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                    + Integer.toHexString(resource) + ")");
        }

        final XmlResourceParser parser = res.getLayout(resource);
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }
 public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
            ...
            final String name = parser.getName();
            final View temp = createViewFromTag(root, name, inflaterContext, attrs);
            ...
            return temp;
    }

可以看到inflate會返回具體的View對象出去,那么我們的關(guān)注焦點就放在createViewFromTag中了

    /**
     * Creates a view from a tag name using the supplied attribute set.
     * <p>
     * <strong>Note:</strong> Default visibility so the BridgeInflater can
     * override it.
     *
     * @param parent the parent view, used to inflate layout params
     * @param name the name of the XML tag used to define the view
     * @param context the inflation context for the view, typically the
     *                {@code parent} or base layout inflater context
     * @param attrs the attribute set for the XML tag used to define the view
     * @param ignoreThemeAttr {@code true} to ignore the {@code android:theme}
     *                        attribute (if set) for the view being inflated,
     *                        {@code false} otherwise
     */
    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;
            }
            return view;
        } catch (Exception e) {
        }
    }

為了方便,這里代碼直接轉(zhuǎn)至 http://www.itdecent.cn/p/eebb8eae5ea1
inflate最終調(diào)用了createViewFromTag方法來創(chuàng)建View,在這之中用到了factory,如果factory存在就用factory創(chuàng)建對象,如果不存在就由系統(tǒng)自己去創(chuàng)建。我們只需要實現(xiàn)我們的Factory然后設(shè)置給mFactory2就可以采集到所有的View了。

到目前為止我們只知道要去自定義一個factory,那么這個東西到底是什么呢? 上面我們通過源碼簡單的了解了,如果factory存在就用factory創(chuàng)建對象,如果不存在就由系統(tǒng)自己去創(chuàng)建view。
那么我們就重寫一個factory

public class SkinLayoutInflateFactory implements LayoutInflater.Factory2 {
    private static final String TAG = SkinLayoutInflateFactory.class.getSimpleName();
    private Activity activity;

    public SkinLayoutInflateFactory(Activity mActivity) {
        this.activity = mActivity;
    }

    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    //do sth
        return null;
    }

    @Nullable
    @Override
    public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {

        return null;
    }
}

可以看到方法onCreateView是創(chuàng)建view的方法,其中AttributeSet表示屬性集。為了方便管理以及盡可能減少代碼的入侵,我們使用ActivityLifecycleCallbacks。

public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
    private static final String TAG = SkinActivityLifecycle.class.getSimpleName();
    private Map<Activity,SkinLayoutInflateFactory> factoryMap = new HashMap<>();

    @Override
    public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
        LayoutInflater layoutInflater = LayoutInflater.from(activity);

        try {
            //Android 布局加載器 使用 mFactorySet 標記是否設(shè)置過Factory
            //如設(shè)置過拋出一次
            //設(shè)置 mFactorySet 標簽為false
            Field mFactorySet = LayoutInflater.class.getDeclaredField("mFactorySet");
            mFactorySet.setAccessible(true);
            mFactorySet.setBoolean(layoutInflater,false);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        Log.d(TAG, "onActivityCreated: ");
        //使用factory2 設(shè)置布局加載工程
        SkinLayoutInflateFactory skinLayoutInflaterFactory = new SkinLayoutInflateFactory(activity);
        LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutInflaterFactory);
        factoryMap.put(activity,skinLayoutInflaterFactory);

    }

    @Override
    public void onActivityStarted(@NonNull Activity activity) {

    }

    @Override
    public void onActivityResumed(@NonNull Activity activity) {

    }

    @Override
    public void onActivityPaused(@NonNull Activity activity) {

    }

    @Override
    public void onActivityStopped(@NonNull Activity activity) {

    }

    @Override
    public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {

    }

    @Override
    public void onActivityDestroyed(@NonNull Activity activity) {
        factoryMap.remove(activity);
    }
}

現(xiàn)在演示下SkinLayoutInflateFactory的使用,我們在SkinLayoutInflateFactory的onCreateView打印AttributeSet屬性

 @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        Log.d(TAG, "onCreateView: name "+ name);
        int attributeCount = attrs.getAttributeCount();
        for (int i = 0; i < attributeCount; i++) {
            Log.d(TAG, "onCreateView: "+attrs.getAttributeName(i)+"---"+attrs.getAttributeValue(i));
        }

        return null;
    }
name androidx.constraintlayout.widget.ConstraintLayout
layout_width----1
layout_height----1
name TextView
layout_width----2
layout_height----2
text---Hello World!
layout_constraintBottom_toBottomOf---0
layout_constraintLeft_toLeftOf---0
layout_constraintRight_toRightOf---0
layout_constraintTop_toTopOf---0

對應的布局

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

可以看到布局和屬性是一一對應的,那么現(xiàn)在我們玩一個好玩的東西,把textview換成button

 @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, 
                @NonNull Context context, @NonNull AttributeSet attrs) {
       switch (name){
           case "TextView":
               Button button = new Button(context);
               button.setText("替換文本");
               button.setTextColor(Color.RED);

               return button;
       }

        return null;
    }
圖片.png

通過以上的實驗我們就可以簡單的理解,自定義factory就可以獲取到需要換膚的控件了,但是控件還包含了自定義控件,比如com.xxx.widget.MyView, 或者Android系統(tǒng)的控件,剩下的就是只有標簽的比如ImageView的控件,但是只帶標簽的控件需要補全包名,android.widget.ImageView.才能轉(zhuǎn)成View
通過以上分析我們先完成控件篩選

private static final String[] mClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };
  @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
      //獲取只帶標簽的控件
        View view = createViewFromTag(name, context, attrs);
        //如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
        if (null == view) {
            view = createView(name, context, attrs);
        }
        if (null != view) {

            L.e(String.format("檢查[%s]:" + name, context.getClass().getName()));

        }
        return view;
    }

 private View createViewFromTag(String name, Context context, AttributeSet
            attrs) {
        //如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
        if (-1 != name.indexOf('.')) {
            return null;
        }
        for (int i = 0; i < mClassPrefixList.length; i++) {
            return createView(mClassPrefixList[i] +
                    name, context, attrs);

        }
        return null;
    }

    private View createView(String name, Context context, AttributeSet
            attrs) {
        L.e(String.format("name= [%s]:",name));
        Constructor<? extends View> constructor = findConstructor(context, name);
        try {
            return constructor.newInstance(context, attrs);
        } catch (Exception e) {
        }
        return null;
    }

    private Constructor<? extends View> findConstructor(Context context, String name) {
        Constructor<? extends View> constructor = mConstructorMap.get(name);
        if (null == constructor) {
            try {
                Class<? extends View> clazz = context.getClassLoader().loadClass
                        (name).asSubclass(View.class);
                constructor = clazz.getConstructor(mConstructorSignature);


                L.e(String.format("constructor name = [%s]",constructor.getName()));

                mConstructorMap.put(name, constructor);
            } catch (Exception e) {
            }
        }
        return constructor;
    }

代碼分析

如果name包含有 " . "則說明可能是自定義控件(比如com.wzw.MyView)或者系統(tǒng)控件(android.support.v4.view.ViewPager)否則需要添加完整包名。出現(xiàn)異常比如循環(huán)中可能出現(xiàn)
android.view.TextView則拋出異常不處理,最后通過反射的原理轉(zhuǎn)換成view。

        //獲取只帶標簽的控件,添加包名轉(zhuǎn)成view
        View view = createViewFromTag(name, context, attrs);
        //如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
        if (null == view) {
            view = createView(name, context, attrs);
        }
    
        return view;
2、加載皮膚包

實際上皮膚包也是一個Android文件,不管你將后綴名改為什么,只要是創(chuàng)建出的Android 項目就會存在res包以及底下的文件,利用這個特點,我們可以將想要替換的資源文件放到對應的包中,那么換膚的時候就只要去加載皮膚包中的對應的圖片就可以了。
好了問題來了,比如我們需要替換某個ImageView的圖片,那么正常的做法是要先加載到皮膚包中的資源文件,context.getResource.getDrawable(xxxx);那么請問,如果我們這么寫能加載到資源嗎?
答案是否定的,因為context是屬于當前app的上下文,并不能加載插件app的資源文件。那么要怎么獲取資源并設(shè)置呢?
我們先來看下Resource的構(gòu)造方法:

@Deprecated
    public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
        this(null);
        mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
    }

先看后面兩個參數(shù)分別表示屏幕相關(guān)參數(shù)和設(shè)備信息。這兩個參數(shù)可以使用本app的context提供,重點看AssetManager
AssetsManager 直接對接Android系統(tǒng)底層。
Assets Manager有一個方法:addAssetPath(String path) 方法,app啟動的時候會把當前的APK路徑傳遞進去,然后我們就可以訪問資源了。
根據(jù)思路我們將插件apk放在app項目的assets文件夾下,然后寫入緩存文件中提供訪問。接著我們必須創(chuàng)建出resource和assetmanger,以及dexclassloader(可訪問未安裝apk類)

 //獲取assetManager
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager,dataFile.getAbsolutePath());

            //獲取到插件resource
            Resources appResource = application.getResources();
            Resources skinResource = new Resources(assetManager,appResource.getDisplayMetrics(),appResource.getConfiguration());

            //獲取插件進程名
            PackageManager packageManager = application.getPackageManager();
            PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(dataFile.getAbsolutePath(), PackageManager.GET_ACTIVITIES);
            String packageName = packageArchiveInfo.applicationInfo.packageName;

            //獲取插件dexClassloader
            File optimizedDirectory = application.getDir("dex", Context.MODE_PRIVATE);
            DexClassLoader dexClassLoader = new DexClassLoader(dataFile.getAbsolutePath(),optimizedDirectory.getAbsolutePath(),
                    null,application.getClassLoader());

            SkinResource.getInstance().init(dexClassLoader,skinResource,appResource,packageName);
3、替換資源

第一步我們以及篩選除了需要替換的控件以及控件的屬性,為了簡單說明,目前我們制作Imageview的background的替換以及Textview的文本顏色替換。

public void setDrawable(ImageView imageView, String drawableName) {
        try {
            Class<?> aClass = dexClassLoader.loadClass(skinPackageName + ".R$mipmap");
            Field field = aClass.getField(drawableName);
            //獲取到圖片的id
            int anInt = field.getInt(R.id.class);
            imageView.setImageDrawable(skinResources.getDrawable(anInt));

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    public void setTextColor(TextView textView, String colorName) {
        try {
            Class<?> aClass = dexClassLoader.loadClass(skinPackageName + ".R$color");
            Field field = aClass.getField(colorName);
            Log.d(TAG, "setTextColor: "+field.getName());
            int anInt = field.getInt(R.id.class);
            textView.setTextColor(skinResources.getColor(anInt));

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

代碼說明:
通過dexClassloader后去mipmap和color資源文件,其中兩個方法中的drawableName和colorName分別表示資源名,比如mipmap中保存了一張 ic_bg.png,那么drawableName 就等于“ ic_bg”。
接著通過field獲取同名資源文件的資源id,最后通過插件的resource對象設(shè)置,達到替換的效果。
。
那么現(xiàn)在的重點就是怎么獲取drawableName(colorName)。
在第一步的時候我們收集了xml中的控件以及屬性

   @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        //獲取只帶標簽的控件
        View view = createViewFromTag(name, context, attrs);
        //如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
        if (null == view) {
            view = createView(name, context, attrs);
        }
        if (view != null) {
            attribute.load(view, attrs);
        }
        return view;
    }

我們繼續(xù)看 attribute.load(view, attrs);方法

public void load(View view, AttributeSet attrs) {
        List<SkinPain> skinPains = new ArrayList<>();
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            //獲取屬性名字
            String attributeName = attrs.getAttributeName(i);
            if (mAttributes.contains(attributeName)) {
                //獲取屬性對應的值
                String attributeValue = attrs.getAttributeValue(i);
                if (attributeValue.startsWith("#")) {
                    continue;
                }

                int resId = 0;
                //判斷前綴字符串 是否是"?"
                //attributeValue  = "?2130903043"
                if (attributeValue.startsWith("?")) {  //系統(tǒng)屬性值

                } else {
                    //@1234564
                    resId = Integer.parseInt(attributeValue.substring(1));
                }
                if (resId != 0) {
                    SkinPain skinPain = new SkinPain(attributeName, resId, view.getClass().getName());
                    skinPains.add(skinPain);
                }
            }
        }
        if (!skinPains.isEmpty() || view instanceof TextView) {
            SkinView skinView = new SkinView(view, skinPains);
//            skinView.applySkin();
            skinViews.add(skinView);
        }
    }

其實以上代碼還是通過attrs這個類獲取到控件名字,控件的屬性,以及通過attrs.getAttributeValue(int i);獲取到了設(shè)置的資源名字,比如?6453213432,通過字符串截取,就可以獲取到當前設(shè)置的resId,緊接利用resId,通過Resources.getResourceEntryName(resId)方法我們就可以獲取到resName了。

   public String getResName(int resId) {
        //R.drawable.ic_launcher
        return appResources.getResourceEntryName(resId);//ic_launcher   /colorPrimaryDark

    }

由于換膚的前提是宿主設(shè)置的資源名和插件的資源名一致,所以通過獲取到宿主設(shè)置的資源名我們就可以獲取到插件的資源名從而設(shè)置進去。
本例子我們使用了Observable觀察者,當點擊按鈕加載資源的時候就通知被觀察設(shè)置插件中的同名資源從而達到了換膚的效果。

總結(jié)

該例子只做了ImageView背景替換和TextView文本顏色替換,當然還有類似自定義控件的替換,文本字體替換等,這里就不做一一解釋。因為我們只要懂得核心就可以舉一反三。總體的步驟就是:
1、采集需要換膚的控件
2、 加載皮膚包
3、 替換資源
他的核心還是離不開Android的插件化
Android插件化從技術(shù)上來說就是如何啟動未安裝的apk(主要是四大組件)里面的類,主要問題涉及如何加載類、如何加載資源、如何管理組件生命周期。
感興趣的可以參考 https://zhuanlan.zhihu.com/p/136001039

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容