說說Android動態換膚實現原理吧

augfun發表於2020-11-17

換膚分為動態換膚和靜態換膚

靜態換膚

這種換膚的方式,也就是我們所說的內建換膚,就是在APP內部放置多套相同的資源。進行資源的切換。
這種換膚的方式有很多缺點,比如, 靈活性差,只能更換內建的資源、apk體積太大,在我們的應用Apk中等一般圖片檔案能佔到apk大小的一半左右。
當然了,這種方式也並不是一無是處, 比如我們的應用內,只是普通的 日夜間模式 的切換,並不需要圖片等的更換,只是更換顏色,那這樣的方式就很實用。

動態換膚

適用於大量皮膚,使用者選擇下載,像QQ、網易雲音樂這種。它是將皮膚包下載到本地,皮膚包其實是個APK。

換膚包括替換圖片資源、佈局顏色、字型、文字顏色、狀態列和導航欄顏色。

動態換膚步驟包括:

  • 採集需要換膚的控制元件
  • 載入皮膚包
  • 替換資源

實現原理

首先Activity的onCreate()方法裡面我們都要去呼叫setContentView(int id) 來指定當前Activity的佈局檔案:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

再往裡看:

    @Override
    public void setContentView(int resId) {
        ensureSubDecor();
        ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
        contentParent.removeAllViews();
        LayoutInflater.from(mContext).inflate(resId, contentParent);//這裡實現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物件出去,那麼我們的關注焦點就放在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) {
        }
    }

inflate最終呼叫了createViewFromTag方法來建立View,在這之中用到了factory,如果factory存在就用factory建立物件,如果不存在就由系統自己去建立。我們只需要實現我們的Factory然後設定給mFactory2就可以採集到所有的View了,這裡是一個Hook點。

當我們採集完了需要換膚的view,下一步就是載入皮膚包資源。當我們拿到當前View的資源名稱時就會先去皮膚外掛中的資原始檔裡找

Android載入資源的流程圖:

 

1.採集換膚控制元件

android解析xml建立view的步驟:

  • setContentView -> window.setContentView()(實現類是PhoneWindow)->mLayoutInflater.inflate() -> inflate … ->createViewFromTag().

所以我們複寫了Factory的onCreateView之後,就可以不通過系統層而是自己截獲從xml對映的View進行相關View建立的操作,包括對View的屬性進行設定(比如背景色,字型大小,顏色等)以實現換膚的效果。如果onCreateView返回null的話,會將建立View的操作交給Activity預設實現的Factory的onCreateView處理。

1.使用ActivityLifecycleCallbacks,儘可能少的去侵入程式碼,在onActivityCreated中監聽每個activity的建立。

@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
       LayoutInflater layoutInflater = LayoutInflater.from(activity);
       try {
           //系統預設 LayoutInflater只能設定一次factory,所以利用反射解除限制
           Field mFactorySet = LayoutInflater.class.getDeclaredField("mFactorySet");
           mFactorySet.setAccessible(true);
           mFactorySet.setBoolean(layoutInflater, false);
       } catch (Exception e) {
           e.printStackTrace();
       }

       //新增自定義建立View 工廠
       SkinLayoutFactory factory = new SkinLayoutFactory(activity, skinTypeface);
       layoutInflater.setFactory2(factory);
}

2.在 SkinLayoutFactory中將每個建立的view進行篩選採集

  //根據tag反射獲取view
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        // 反射 classLoader
        View view = createViewFromTag(name, context, attrs);
        // 自定義View
        if(null ==  view){
            view = createView(name, context, attrs);
        }

        //篩選符合屬性View
        skinAttribute.load(view, attrs);

        return view;
    }

3.將view封裝成物件

    //view的引數物件
    static class SkinPain {
        String attributeName;
        int resId;

        public SkinPain(String attributeName, int resId) {
            this.attributeName = attributeName;
            this.resId = resId;
        }
    }

    //view物件
     static class SkinView {
        View view;
        List<SkinPain> skinPains;

        public SkinView(View view, List<SkinPain> skinPains) {
            this.view = view;
            this.skinPains = skinPains;
        }
     }

將屬性符合的view儲存起來

public class SkinAttribute {
    private static final List<String> mAttributes = new ArrayList<>();

    static {
        mAttributes.add("background");
        mAttributes.add("src");

        mAttributes.add("textColor");
        mAttributes.add("drawableLeft");
        mAttributes.add("drawableTop");
        mAttributes.add("drawableRight");
        mAttributes.add("drawableBottom");

        mAttributes.add("skinTypeface");
    }

    private List<SkinView> skinViews = new ArrayList<>();

    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;
                //判斷字首字串 是否是"?"
                //attributeValue  = "?2130903043"
                if (attributeValue.startsWith("?")) {  //系統屬性值
                    //字串的子字串  從下標 1 位置開始
                    int attrId = Integer.parseInt(attributeValue.substring(1));
                    resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
                } else {
                    //@1234564
                    resId = Integer.parseInt(attributeValue.substring(1));
                }
                if (resId != 0) {
                    SkinPain skinPain = new SkinPain(attributeName, resId);
                    skinPains.add(skinPain);
                }
            }
        }
        //SkinViewSupport是自定義view實現的介面,用來區分是否需要換膚
        if (!skinPains.isEmpty() || view instanceof TextView || view instanceof SkinViewSupport) {
            SkinView skinView = new SkinView(view, skinPains);
            skinView.applySkin(mTypeface);
            skinViews.add(skinView);
        }
    }

    ...

    }

2.載入皮膚包

載入皮膚包需要我們動態獲取網路下載的皮膚包資源,問題是我們如何載入皮膚包中的資源

Android訪問資源使用的是Resources這個類,但是程式裡面通過getContext獲取到的Resources例項實際上是對應程式本來的資源的例項,也就是說這個例項只能載入app裡面的資源,想要載入皮膚包裡面的就不行了

自己構造一個Resources(這個Resources指向的資源就是我們的皮膚包)
看看Resources的構造方法,可以看到主要是需要一個AssetManager

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

構造一個指向皮膚包的AssetManager,但是這個AssetManager是不能直接new出來的,這裡就使用反射來例項化了

AssetManager assetManager = AssetManager.class.newInstance();

AssetManager有一個addAssetPath方法可以指定資源的位置,可惜這個也只能用反射來呼叫

Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
        addAssetPath.invoke(assetManager, filePath);

再來看看Resources的其他兩個引數,一個是DisplayMetrics,一個是Configuration,這兩的就可以直接使用app原來的Resources裡面的就可以。

具體程式碼如下:

    public void loadSkin(String path) {
        if(TextUtils.isEmpty(path)){
            // 記錄使用預設皮膚
            SkinPreference.getInstance().setSkin("");
            //清空資源管理器, 皮膚資源屬性等
            SkinResources.getInstance().reset();
        } else {
            try {
                //反射建立AssetManager
                AssetManager manager = AssetManager.class.newInstance();
                // 資料路徑設定
                Method addAssetPath = manager.getClass().getMethod("addAssetPath", String.class);
                addAssetPath.invoke(manager, path);

                Resources appResources = this.application.getResources();
                Resources skinResources = new Resources(manager,
                        appResources.getDisplayMetrics(), appResources.getConfiguration());

                //記錄當前皮膚包
                SkinPreference.getInstance().setSkin(path);
                //獲取外部Apk(皮膚薄) 包名
                PackageManager packageManager = this.application.getPackageManager();
                PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
                String packageName = packageArchiveInfo.packageName;

                SkinResources.getInstance().applySkin(skinResources,packageName);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        setChanged();
        //通知觀者者,進行替換資源
        notifyObservers();
    }

3.替換資源

換膚的核心操作就是替換資源,這裡採用觀察者模式,被觀察者是我們的換膚管理類SkinManager,觀察者是我們之前快取的每個頁面的LayoutInflater.Factory2

    @Override
    public void update(Observable o, Object arg) {
        //狀態列
        SkinThemeUtils.updataStatusBarColor(activity);
        //字型
        Typeface skinTypeface = SkinThemeUtils.getSkinTypeface(activity);
        skinAttribute.setTypeface(skinTypeface);
        //更換皮膚
        skinAttribute.applySkin();
    }

applySkin()在去遍歷每個factory快取的需要換膚的view,呼叫他們的換膚方法

    public void applySkin() {
        for (SkinView mSkinView : skinViews) {
            mSkinView.applySkin(mTypeface);
        }
    }

applySkin方法如下:

        public void applySkin(Typeface typeface) {
            //換字型
            if(view instanceof TextView){
                ((TextView) view).setTypeface(typeface);
            }
            //自定義view換膚
            if(view instanceof SkinViewSupport){
                ((SkinViewSupport)view).applySkin();
            }

            for (SkinPain skinPair : skinPains) {
                Drawable left = null, top = null, right = null, bottom = null;
                switch (skinPair.attributeName) {
                    case "background":
                        Object background = SkinResources.getInstance().getBackground(
                                skinPair.resId);
                        //Color
                        if (background instanceof Integer) {
                            view.setBackgroundColor((Integer) background);
                        } else {
                            ViewCompat.setBackground(view, (Drawable) background);
                        }
                        break;
                    case "src":
                        background = SkinResources.getInstance().getBackground(skinPair
                                .resId);
                        if (background instanceof Integer) {
                            ((ImageView) view).setImageDrawable(new ColorDrawable((Integer)
                                    background));
                        } else {
                            ((ImageView) view).setImageDrawable((Drawable) background);
                        }
                        break;
                    case "textColor":
                        ((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList
                                (skinPair.resId));
                        break;
                    case "drawableLeft":
                        left = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableTop":
                        top = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableRight":
                        right = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "drawableBottom":
                        bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
                        break;
                    case "skinTypeface" :
                        applyTypeface(SkinResources.getInstance().getTypeface(skinPair.resId));
                        break;
                    default:
                        break;
                }
                if (null != left || null != right || null != top || null != bottom) {
                    ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
                            bottom);
                }
            }
        }

這裡能看到換膚的實現方式就是根據原始資源Id來獲取皮膚包的資源Id,從而載入資源。因此我們要保證app和皮膚包的資源名稱一致

    public Drawable getDrawable(int resId) {
        //如果有皮膚  isDefaultSkin false 沒有就是true
        if (isDefaultSkin) {
            return mAppResources.getDrawable(resId);
        }
        int skinId = getIdentifier(resId);//查詢對應的資源id
        if (skinId == 0) {
            return mAppResources.getDrawable(resId);
        }
        return mSkinResources.getDrawable(skinId);
    }

    //獲取皮膚包中對應資源的id
    public int getIdentifier(int resId) {
        if (isDefaultSkin) {
            return resId;
        }
        //在皮膚包中的資源id不一定就是 當前程式的 id
        //獲取對應id 在當前的名稱 例如colorPrimary
        String resName = mAppResources.getResourceEntryName(resId);//ic_launcher   /colorPrimaryDark
        String resType = mAppResources.getResourceTypeName(resId);//drawable
        int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);//使用皮膚包的Resource
        return skinId;
    }

4.皮膚包的生成

其實很簡單,就是我們重新建立一個專案(這個專案裡面的資源名字和需要換膚的專案的資源名字是對應的就可以),記住我們是通過名字去獲取資源,不是id

  1. 新建工程project
  2. 將換膚的資原始檔新增到res檔案下,無java檔案
  3. 直接執行build.gradle,生成apk檔案(注意,執行時Run/Redebug configurations 中Launch Options選擇launch nothing),否則build 會報 no default Activty的錯誤。
  4. 將apk檔案重新命名,如black.apk重新命名為black.skin防止使用者點選安裝

作者:打王者的程式設計師
https://blog.csdn.net/hxl517116279/article/details/96581407



作者:位元組走動_Android
連結:https://www.jianshu.com/p/53ed7ba95722
來源:簡書
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

相關文章