1. 程式人生 > >Android動態換膚框架實現

Android動態換膚框架實現

今天介紹一下Android 中的常用的換膚策略,同時動手實現一個動態換膚的框架

先上效果圖:

 

換膚概念

 
換膚: 在android中是指 對 文字、 顏色、 圖片 等的資源的更換。
: 對應於現實生活中,就是我們的 膚色 、 衣服 等的更換。
 
有什麼好處或者說 目的是什麼 ??
對應於我們android 中呢,就是 可以 滿足使用者的新鮮感,可以提高使用者的留存率,當然 面板也可以進行收費。 這就好比我們生活中 去旅行時,有普通的酒店, 也有很多主題酒店,有各種各樣的…, 你會去哪個呢?

 

換膚實現方式

在我們的開發中, 一般常用的有兩種換膚的方式:
 

第一種: 靜態換膚:

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

 
囉嗦了半天… 是時候開始正式進入我們今天的主題.
 
~
~
~
Ok .

第二種:動態換膚

怎麼個動態法呢? 即在執行時動態的載入面板包
 
這麼說可能有點不是很好理解,大白話就是,當控制元件初始化完畢,需要設定內容(圖片顏色 等)時,動態的去我們對應的面板包去找。
 
~ 我這麼說你可能覺的這跟靜態換膚沒啥不同都是 需要去 set
 
我們這裡呢,強調兩點 :
1.動態 ,即靈活性比較高,可以隨意的切換 , 當然這是基於第二點上 .

2.面板包,這個和apk是分離開來的,我們可以去網路,去伺服器獲取到面板包,想怎麼換就怎麼換。
 
是時候開始表演真正的技術了, 哈哈~~~


 
在開始之前,我們先重申一點: 我們今天要做的是框架。 所以我們要站在框架開發者的角度, 怎麼讓使用這更加方便、更加簡潔的使用。
 
Ok, 開始 !
 
先總結一下我們的整體步驟:
1.採集控制元件 獲取需要換膚的控制元件。(要換膚,你總得拿到或者知道要換哪些控制元件吧)
2.載入面板包 載入我們的外接面板包。
3.換膚 這一步呢,我們需要控制元件與面板包資源的匹配,進行換膚.
 

採集控制元件

我們需要去拿到每一個頁面的 所有需要換膚的控制元件。你可能會想到在每個ActivityOnCreate()中去獲取,然後儲存到集合中。 stop! ~~~ ok 我們要做的是一個框架,要做到儘可能少的去 侵入使用者的程式碼。 下面這個 api, 可以拿到Activity的回撥。
 

application.registerActivityLifecycleCallbacks(引數)

 
這個方法可以獲取到Activity的生命週期回撥 ActivityLifecycleCallbacks。
 
ok,下面我們來點實際的。
 
我們新建一個類,用來做初始化,application 中初始化。
 


public class SkinChangeManager { 
   private static final String TAG = SkinChangeManager.class.getSimpleName(); 
   private static SkinChangeManager _Instance = null;  
   private static Application mContext;    
   private ActivityCallbacks mActivityCallBacks;   
   public static void init(Application context) {  
          mContext = context;       
         synchronized (SkinChangeManager.class) {   
             if (_Instance == null) {         
               _Instance = new SkinChangeManager();    
            }     
          } 
      }  
  public SkinChangeManager() {  
    SkinPreference.init(mContext); // sp 儲存 
    SkinResources.init(mContext);  // 資源的獲取    
    //註冊activity的回撥 此方法執行在 super.xxx 之前  
    mActivityCallBacks = new ActivityCallbacks();
    mContext.registerActivityLifecycleCallbacks(mActivityCallBacks);
    }    

  public static SkinChangeManager getInstance() { 
       return _Instance;    
  }

 
ok, 在構造方法 的初始化中,我們 建立了 ActivityLifecycleCallbacks 的例項,並進行了註冊 registerActivityLifecycleCallbacks .

 
下面是 ActivityCallbacks的程式碼:


@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {

}

@Override
public void onActivityStarted(Activity activity) {

}

@Override
public void onActivityResumed(Activity activity) {

}
    ...

 
你或許已經猜到了, 我們要搞點事的地方,沒錯,就是她 onActivityCreated

 
ok~~~,接下來我們要拿到所有的View 。這下才是重點。
 
需要拿到View,我們就要知道setContentView是幹了什麼事。

 
簡單描述下流程:

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

 
下面這段是 系統 createViewFromTag() 中的部分程式碼:


    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;

 
ok,非常簡單,首先判斷了mFactory2mFactory 是否為null。然後,判斷是否是系統View if (-1 ==
name.indexOf('.'))
那麼 拼接字首進行反射建立例項,如果是自定義的View ,則直接去建立了例項。
 
這裡我們的思路是 View部分的建立我們自己來完成.
即給LayoutInflater 設定一個 Factory 我們模仿系統的實現來給系統返回一個View,當然我們也可以在中間做一些我們想做的操作。


@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {

    LayoutInflater inflater = LayoutInflater.from(activity);

    SkinViewFactory skinFactory = new SkinViewFactory(activity,mSkinTypeface);

    inflater.setFactory2(skinFactory);

}

 
ok, 我們來看 SkinFactory的實現:


public class SkinViewFactory implements LayoutInflater.Factory2{

    private Activity mActivity;
    private SkinAttribute mSkinAttrs;   // 此View 用來儲存 所有我們需要換膚的view

    private static final Class<?>[] mConstructorSignature = new Class[]{
            Context.class, AttributeSet.class}; // 兩個引數的簽名


    private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
            new HashMap<String, Constructor<? extends View>>();
    private static final String[] mClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };

    public SkinViewFactory(Activity activity, Typeface mSkinTypeface) {
        this.mActivity = activity;
        mSkinAttrs = new SkinAttribute(mSkinTypeface);
    }

    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        View view = createViewFromTag(name, context, attrs);

        //自定義view的情況  這裡是全類名
        if (view == null) {
            view = createView(name, context, attrs);
        }

        //view 建立完畢了, 我們要從其中晒選出我們需要 換膚的view
        mSkinAttrs.load(view,attrs);
        return view;
    }

    private View createViewFromTag(String name, Context context, AttributeSet attrs) {

        View view = null;
        if (-1 != name.indexOf('.')) {  // 包含了. 自定義view
            return null;
        }

        for (int i = 0; i < mClassPrefixList.length; i++) {     // 拼接 然後 生成view
            view = createView(mClassPrefixList[i] + name, context, attrs);
            if (view != null) {
                break;
            }
        }
        return view;
    }

    private View createView(String name, Context context, AttributeSet attrs) {
        Constructor<? extends View> constructor = sConstructorMap.get(name);
        if (constructor == null) {
            try {
                Class<? extends View> aClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
                constructor = aClass.getConstructor(mConstructorSignature);
                sConstructorMap.put(name, constructor);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        if (null != constructor) {
            try {
                return constructor.newInstance(context, attrs);

            } catch (InstantiationException e) {

            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return null;
    }
}

 
SkinFactory 中我們做了兩件事:

  1. View的建立並返回,由系統來進行後續的操作
  2. 我們建立了一個實體 SkinAttribute 並呼叫了 mSkinAttrs.load(view,attrs); 來進行View的篩選。

我們接著來看篩選的邏輯部分, 即mSkinAttrs.load(view,attrs);


/**     
* 獲取view 中的屬性, 過濾出 需要換膚的View 以及 相關的屬性 儲存起來     
*     
* @param view  
* @param set     
*/    
public void load(View view, AttributeSet set) {     
    List<SkinPair> skinPairs = new ArrayList<>();        
    int attributeCount = set.getAttributeCount();        
    for (int i = 0; i < attributeCount; i++) {            
        String attrName = set.getAttributeName(i);            
        if (mAttributes.contains(attrName)) {                
            int resId;                
            String attributeValue = set.getAttributeValue(i);                
            Log.e(TAG, "attribute name  :" + attrName + " attribute value " + attributeValue);                
            if (attributeValue.startsWith("#")) {    
                // 如果寫固定了,比如#111111 我們則不做操作                    
                continue;
            } else if (attributeValue.startsWith("?")) {
                // 主題中的資源                    
                int attrId = Integer.parseInt(attributeValue.substring(1)); 
                // 獲取到attrId                    
                resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];                                                } else {                    
                //@1213432  格式                    
                resId = Integer.parseInt(attributeValue.substring(1));  
                // 剪下之前 類似 @2131165298                
            }                

            if (resId != 0) {    
               // 儲存對應的 name - id    比如. drawable - 121321423                                                             SkinPair skinPair = new SkinPair(attrName, resId);                                                             skinPairs.add(skinPair);                
            }            
        }        
    }        
    if (!skinPairs.isEmpty()) {            
        SkinView skinView = new SkinView(view, skinPairs);                                                        skinView.applySkin(mSkinTypeface);            
        mSkinViews.add(skinView);        
    }    
}

 
在下面我們定義了一個 所要過濾View 屬性的集合。 即滿足該屬性的View,都會被儲存起來。當然,你也可以新增你想過濾的屬性到裡面。


    //用來記錄所有需要更換面板的 屬性    
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");    
    }

 
以上,我們就把 採集控制元件 的步驟完成了, 把需要換膚的控制元件 都 儲存了起來,具體的程式碼我會在 文章放上鍊接。 大家可以下載下來詳細的看流程。
 

載入面板包

即把面板包(外接的apk或者zip檔案)載入進來。 下面是SkinChangeManager.java 中的程式碼。


/**
 * 載入面板包
 *
 * @param path
 */
public void loadSkin(String path) {
    if (TextUtils.isEmpty(path)) {
        SkinPreference.getInstance().setSkin("");
        SkinResources.getInstance().reset();
    } else {
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
            addAssetPath.setAccessible(true);
            addAssetPath.invoke(assetManager, path);

            Resources mAppResource = mContext.getResources();

            //獲取外部Apk(面板包) 包名
            PackageManager mPm = mContext.getPackageManager();
            PackageInfo info = mPm.getPackageArchiveInfo(path, PackageManager
                    .GET_ACTIVITIES);
            String packageName = info.packageName;

            Resources mSkinResources = new Resources(assetManager, mAppResource.getDisplayMetrics(), mAppResource.getConfiguration());

            Log.e(TAG,"package Name : "+packageName);

            SkinResources.getInstance().applySkin(mSkinResources, packageName);

            SkinPreference.getInstance().setSkin(path);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 
以上的程式碼完成 載入面板包,並 得到面板包 對應的 Resources
然後交給了 SkinResources 來處理。就這麼簡單?
 
沒錯,面板包載入完了 ~~~
 
我們需要稍微提一下Android 載入資源的方式:
這裡寫圖片描述

 
ok, 你理解了上面的圖中的圖片載入方式,就能理解SkinResources 的程式碼操作了.下面是部分 SkinResources的程式碼:


    public int getIdentifier(int resId) {        
        if (isDefaultSkin) {            
            return resId;        
        }        
          //在面板包中不一定就是 當前程式的 id        
          //獲取對應id 在當前的名稱 colorPrimary        
          //R.drawable.ic_launcher        
          String resName = mAppResources.getResourceEntryName(resId);//ic_launcher        
          String resType = mAppResources.getResourceTypeName(resId);//drawable        
        int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);                                return skinId;    
    }    
    public int getColor(int resId) {        
        if (isDefaultSkin) {            
            return mAppResources.getColor(resId);        
        }        
        int skinId = getIdentifier(resId);        
        if (skinId == 0) {            
            return mAppResources.getColor(resId);        
        }        
        return mSkinResources.getColor(skinId);    
    }

 
稍微解釋一下: 當你現在使用的是apk的預設面板,會直接 mAppResources.getColor(resId); 如果不是 預設面板,就會通過 resId 獲取到對應的資源名資源型別 ,然後去面板包去取。
 
說了半天了,終於該到最後一步了.
 

換膚

在我們的 SkinAttribute 這個類中,有一個類 SkinView 用來記錄換膚的View 和 View對應的屬性 key(比如 textColor屬性) 。
 
面向物件的思想,誰知道你該穿什麼衣服? 當然是你自己咯 ~ 。
所以我們的 換膚方法 就在SkinView中 :


/**
 * 應用面板, 換膚 是View的 方法 所以我們在 View包裝類 的內部進行 面板的切換
 * @param mSkinTypeface
 */
public void applySkin(Typeface mSkinTypeface) {
    for (SkinPair skinPair : skinPairs) {
        Drawable left = null, top = null, right = null, bottom = null;
        switch (skinPair.attributesName) {
            case "background":
                Object background = SkinResources.getInstance().getBackground(skinPair.resId);
                // Color
                if (background instanceof Integer) {
                    view.setBackgroundColor((Integer) background);
                } else {
                    view.setBackgroundDrawable((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;
            default:
                break;
        }
        if (null != left || null != right || null != top || null != bottom) {
            ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
                    bottom);
        }
    }
}

 

總結:

你知道動態的換膚需要幾步麼?那你知道把大象裝進冰箱需要幾步麼?
 
沒錯,三步就夠了。
 
第一步:採集所有需要換膚的控制元件,儲存起來。
 
第二步:載入面板包。
 
第三步:換膚(讓控制元件對應上指定的資源)

 
到這裡,我們的換膚的整體思路與程式碼邏輯就結束,還有一些關於 字型狀態列自定義View 等的換膚,整體的思想是一致的,大家可以去參看完整的原始碼。

程式碼地址
https://download.csdn.net/download/mike_cui_ls/10311537