天天看點

Android換膚原理和Android-Skin-Loader架構解析

Android換膚原理和Android-Skin-Loader架構解析

前言

Android換膚技術已經是很久之前就已經被成熟使用的技術了,然而我最近才在學習和接觸熱修複的時候才看到。在看了一些換膚的方法之後,并且對市面上比較認可的​

​Android-Skin-Loader​

​換膚架構的源碼進行了分析總結。再次記錄一下祭奠自己逝去的時間。

文章目錄

  • ​​前言​​
  • ​​換膚介紹​​
  • ​​換膚方式一:切換使用主題Theme​​
  • ​​換膚方式二:加載資源包​​
  • ​​Android換膚知識點​​
  • ​​換膚相應的API​​
  • ​​AssetManager構造​​
  • ​​換膚Resources構造​​
  • ​​使用資源包中的資源換膚​​
  • ​​LayoutInflater.Factory​​
  • ​​Android-Skin-Loader解析​​
  • ​​初始化​​
  • ​​構造換膚對象​​
  • ​​定義基類​​
  • ​​SkinInflaterFactory​​
  • ​​構造View​​
  • ​​對生産的View進行換膚​​
  • ​​資源擷取​​
  • ​​其他​​
  • ​​總結​​

換膚介紹

換膚本質上是對資源的一中替換包括、字型、顔色、背景、圖檔、大小等等。當然這些我們都有成熟的api可以通過控制代碼邏輯做到。比如View的修改背景顔色​

​setBackgroundColor​

​​,TextView的​

​setTextSize​

​修改字型等等。但是作為程式員我們怎麼能忍受對每個頁面的每個元素一個行行代碼做換膚處理呢?我們需要用最少的代碼實作最容易維護和使用效果完美(動态切換,及時生效)的換膚架構。

換膚方式一:切換使用主題Theme

使用相同的資源id,但在不同的Theme下邊自定義不同的資源。我們通過主動切換到不同的Theme進而切換界面元素建立時使用的資源。這種方案的代碼量不多發,而且有個很明顯的缺點不支援已經建立界面的換膚,必須重新加載界面元素。​​GitHub Demo​​

換膚方式二:加載資源包

加載資源包是各種應用程式都在使用的換膚方法,例如我們最常用的輸入法皮膚、浏覽器皮膚等等。我們可以将皮膚的資源檔案放入安裝包内部,也可以進行下載下傳緩存到磁盤上。Android的應用程式可以使用這種方式進行換膚。GitHub上面有一個start非常高的換膚架構​​Android-Skin-Loader​​ 就是通過加載資源包對app進行換膚。對這個架構的分析這個也是這篇文章主要的講述内容。

對比一下發現切換Theme可以進行小幅度的換膚設定(比如某個自定義元件的主題),而如果我們想要對整個app做主題切換那麼通過加載資源包的這種方式目前應該說是比較好的了。

Android換膚知識點

換膚相應的API

我們先來看一下Android提供的一些基本的api,通過使用這些api可以在App内部進行資源對象的替換。

public class Resources {
    public String getString(int id) throws NotFoundException {
        CharSequence res = mAssets.getResourceText(id);
        if (res != null) {
            return res;
        }
        throw new NotFoundException("String resource ID #0x"
                                    + Integer.toHexString(id));
    }
    public Drawable getDrawable(int id) throws NotFoundException {
        /********部分代碼省略*******/
    }
    public int getColor(int id) throws NotFoundException {{
        /********部分代碼省略*******/
    }
    /********部分代碼省略*******/
}      

這個是我們常用的Resources類的api,我們通常可以使用在資源檔案中定義的​

​@+id​

​String類型,然後在編譯出的R.java中對應的資源檔案生産的id(int類型),進而通過這個id(int類型)調用Resources提供的這些api擷取到對應的資源對象。這個在同一個app下沒有任何問題,但是在皮膚包中我們怎麼擷取這個id值呢。

public class Resources {
    /********部分代碼省略*******/
    /**
     * 通過給的資源名稱傳回一個資源的辨別id。
     * @param name 描述資源的名稱
     * @param defType 資源的類型
     * @param defPackage 包名
     * 
     * @return 傳回資源id,0辨別未找到該資源
     */
    public int getIdentifier(String name, String defType, String defPackage) {
        if (name == null) {
            throw new NullPointerException("name is null");
        }
        try {
            return Integer.parseInt(name);
        } catch (Exception e) {
            // Ignore
        }
        return mAssets.getResourceIdentifier(name, defType, defPackage);
    }
}      

Resources提供了可以通過​

​@+id​

​、Type、PackageName這三個參數就可以在AssetManager中尋找相應的PackageName中有沒有Type類型并且id值都能與參數對應上的id,進行傳回。然後我們可以通過這個id再調用Resource的擷取資源的api就可以得到相應的資源。

這裡我們需要注意的一點是​

​getIdentifier(String name, String defType, String defPackage)​

​​方法和​

​getString(int id)​

​方法所調用Resources對象的mAssets對象必須是同一個,并且包含有PackageName這個資源包。

AssetManager構造

怎麼構造一個包含特定packageName資源的AssetManager對象執行個體呢?

public final class AssetManager implements AutoCloseable {
    /********部分代碼省略*******/
    /**
     * Create a new AssetManager containing only the basic system assets.
     * Applications will not generally use this method, instead retrieving the
     * appropriate asset manager with {@link Resources#getAssets}.    Not for
     * use by applications.
     * {@hide}
     */
    public AssetManager() {
        synchronized (this) {
            if (DEBUG_REFS) {
                mNumRefs = 0;
                incRefsLocked(this.hashCode());
            }
            init(false);
            if (localLOGV) Log.v(TAG, "New asset manager: " + this);
            ensureSystemAssets();
        }
    }      

從AssetManager的構造函數來看有​

​{@hide}​

​的朱姐,是以在其他類裡面是直接建立AssetManager執行個體。但是不要忘記Java中還有反射機制可以建立類對象。

AssetManager assetManager = AssetManager.class.newInstance();      

讓建立的assetManager包含特定的PackageName的資源資訊,怎麼辦?我們在AssetManager中找到相應的api可以調用。

public final class AssetManager implements AutoCloseable {
    /********部分代碼省略*******/
    /**
     * Add an additional set of assets to the asset manager.  This can be
     * either a directory or ZIP file.  Not for use by applications.  Returns
     * the cookie of the added asset, or 0 on failure.
     * {@hide}
     */
    public final int addAssetPath(String path) {
        synchronized (this) {
            int res = addAssetPathNative(path);
            if (mStringBlocks != null) {
                makeStringBlocks(mStringBlocks);
            }
            return res;
        }
    }
}      

同樣改方法也不支援外部調用,我們隻能通過反射的方法來調用。

/**
 * apk路徑
 */
String apkPath = Environment.getExternalStorageDirectory()+"/skin.apk";
AssetManager assetManager = null;
try {
    AssetManager assetManager = AssetManager.class.newInstance();
    AssetManager.class.getDeclaredMethod("addAssetPath", String.class).invoke(assetManager, apkPath);
} catch (Throwable th) {
    th.printStackTrace();
}      

至此我們可以構造屬于自己換膚的Resources了。

換膚Resources構造

public Resources getSkinResources(Context context){
    /**
     * 插件apk路徑
     */
    String apkPath = Environment.getExternalStorageDirectory()+"/skin.apk";
    AssetManager assetManager = null;
    try {
        AssetManager assetManager = AssetManager.class.newInstance();
        AssetManager.class.getDeclaredMethod("addAssetPath", String.class).invoke(assetManager, apkPath);
    } catch (Throwable th) {
        th.printStackTrace();
    }
    return new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
}      

使用資源包中的資源換膚

我們将上述所有的代碼組合在一起就可以實作,使用資源包中的資源對app進行換膚。

public Resources getSkinResources(Context context){
    /**
     * 插件apk路徑
     */
    String apkPath = Environment.getExternalStorageDirectory()+"/skin.apk";
    AssetManager assetManager = null;
    try {
        AssetManager assetManager = AssetManager.class.newInstance();
        AssetManager.class.getDeclaredMethod("addAssetPath", String.class).invoke(assetManager, apkPath);
    } catch (Throwable th) {
        th.printStackTrace();
    }
    return new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
}
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    ImageView imageView = (ImageView) findViewById(R.id.imageView);
    TextView textView = (TextView) findViewById(R.id.text);
    /**
     * 插件資源對象
     */
    Resources resources = getSkinResources(this);
    /**
     * 擷取圖檔資源
     */
    Drawable drawable = resources.getDrawable(resources.getIdentifier("night_icon", "drawable","com.tzx.skin"));
    /**
     * 擷取Color資源
     */
    int color = resources.getColor(resources.getIdentifier("night_color","color","com.tzx.skin"));

    imageView.setImageDrawable(drawable);
    textView.setText(text);

}      

通過上述介紹,我們可以簡單的對目前頁面進行換膚了。但是想要做出一個一個成熟換膚架構那麼僅僅這些還是不夠的,提高一下我們的思維高度,如果我們在View建立的時候就直接使用皮膚資源包中的資源檔案,那麼這無疑就使換膚更加的簡單已維護。

LayoutInflater.Factory

看過我前一篇​​遇見LayoutInflater&Factory​​文章的這部分可以省略掉.

很幸運Android給我們在View生産的時候做修改提供了法門。

public abstract class LayoutInflater {
    /***部分代碼省略****/
    public interface Factory {
        public View onCreateView(String name, Context context, AttributeSet attrs);
    }

    public interface Factory2 extends Factory {
        public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
    }
    /***部分代碼省略****/
}      

我們可以給目前的頁面的Window對象在建立的時候設定Factory,那麼在Window中的View進行建立的時候就會先通過自己設定的Factory進行建立。Factory使用方式和相關注意事項請移位到​​遇見LayoutInflater&Factory​​,關于Factory的相關知識點盡在其中。

Android-Skin-Loader解析

初始化

  • 初始化換膚架構,導入需要換膚的資源包(目前為一個apk檔案,其中隻有資源檔案)。
public class SkinApplication extends Application {
  public void onCreate() {
    super.onCreate();
    initSkinLoader();
  }
  /**
   * Must call init first
   */
  private void initSkinLoader() {
    SkinManager.getInstance().init(this);
    SkinManager.getInstance().load();
  }
}      

構造換膚對象

  • 導入需要換膚的資源包,并構造換膚的Resources執行個體。
/**
 * Load resources from apk in asyc task
 * @param skinPackagePath path of skin apk
 * @param callback callback to notify user
 */
public void load(String skinPackagePath, final ILoaderListener callback) {
  
  new AsyncTask<String, Void, Resources>() {

    protected void onPreExecute() {
      if (callback != null) {
        callback.onStart();
      }
    };

    @Override
    protected Resources doInBackground(String... params) {
      try {
        if (params.length == 1) {
          String skinPkgPath = params[0];
          
          File file = new File(skinPkgPath); 
          if(file == null || !file.exists()){
            return null;
          }
          
          PackageManager mPm = context.getPackageManager();
          //檢索程式外的一個安裝封包件
          PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
          //擷取安裝包報名
          skinPackageName = mInfo.packageName;
                    //建構換膚的AssetManager執行個體
          AssetManager assetManager = AssetManager.class.newInstance();
          Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
          addAssetPath.invoke(assetManager, skinPkgPath);
                    //建構換膚的Resources執行個體
          Resources superRes = context.getResources();
          Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());
          //存儲目前皮膚路徑
          SkinConfig.saveSkinPath(context, skinPkgPath);
          
          skinPath = skinPkgPath;
          isDefaultSkin = false;
          return skinResource;
        }
        return null;
      } catch (Exception e) {
        e.printStackTrace();
        return null;
      }
    };

    protected void onPostExecute(Resources result) {
      mResources = result;

      if (mResources != null) {
        if (callback != null) callback.onSuccess();
        //更新多有可換膚的界面
        notifySkinUpdate();
      }else{
        isDefaultSkin = true;
        if (callback != null) callback.onFailed();
      }
    };

  }.execute(skinPackagePath);
}      

定義基類

  • 換膚頁面的基類的通用代碼實作基本換膚功能。
public class BaseFragmentActivity extends FragmentActivity implements ISkinUpdate, IDynamicNewView{
    
    /***部分代碼省略****/
    
    //自定義LayoutInflater.Factory
    private SkinInflaterFactory mSkinInflaterFactory;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    
        try {
            //設定LayoutInflater的mFactorySet為true,表示還未設定mFactory,否則會抛出異常。
            Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
            field.setAccessible(true);
            field.setBoolean(getLayoutInflater(), false);
            //設定LayoutInflater的MFactory
            mSkinInflaterFactory = new SkinInflaterFactory();
            getLayoutInflater().setFactory(mSkinInflaterFactory);

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

    @Override
    protected void onResume() {
        super.onResume();
        //注冊皮膚管理對象
        SkinManager.getInstance().attach(this);
    }
    
    @Override
    protected void onDestroy() {
        super.onDestroy();
        //反注冊皮膚管理對象
        SkinManager.getInstance().detach(this);
    }
    /***部分代碼省略****/
}      

SkinInflaterFactory

  • SkinInflaterFactory進行View的建立并對View進行換膚。

構造View

public class SkinInflaterFactory implements Factory {
    /***部分代碼省略****/
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        //讀取View的skin:enable屬性,false為不需要換膚
        // if this is NOT enable to be skined , simplly skip it 
        boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
        if (!isSkinEnable){
                return null;
        }
        //建立View
        View view = createView(context, name, attrs);
        if (view == null){
            return null;
        }
        //如果View建立成功,對View進行換膚
        parseSkinAttr(context, attrs, view);
        return view;
    }
    //建立View,類比可以檢視LayoutInflater的createViewFromTag方法
    private View createView(Context context, String name, AttributeSet attrs) {
        View view = null;
        try {
            if (-1 == name.indexOf('.')){
                if ("View".equals(name)) {
                    view = LayoutInflater.from(context).createView(name, "android.view.", attrs);
                } 
                if (view == null) {
                    view = LayoutInflater.from(context).createView(name, "android.widget.", attrs);
                } 
                if (view == null) {
                    view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs);
                } 
            }else {
                view = LayoutInflater.from(context).createView(name, null, attrs);
            }

            L.i("about to create " + name);

        } catch (Exception e) { 
            L.e("error while create 【" + name + "】 : " + e.getMessage());
            view = null;
        }
        return view;
    }
}      

對生産的View進行換膚

public class SkinInflaterFactory implements Factory {
    //存儲目前Activity中的需要換膚的View
    private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();
    /***部分代碼省略****/
    private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
        //目前View的所有屬性标簽
        List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();
        
        for (int i = 0; i < attrs.getAttributeCount(); i++){
            String attrName = attrs.getAttributeName(i);
            String attrValue = attrs.getAttributeValue(i);
            
            if(!AttrFactory.isSupportedAttr(attrName)){
                continue;
            }
            //過濾view屬性标簽中屬性的value的值為引用類型
            if(attrValue.startsWith("@")){
                try {
                    int id = Integer.parseInt(attrValue.substring(1));
                    String entryName = context.getResources().getResourceEntryName(id);
                    String typeName = context.getResources().getResourceTypeName(id);
                    //構造SkinAttr執行個體,attrname,id,entryName,typeName
                    //屬性的名稱(background)、屬性的id值(int類型),屬性的id值(@+id,string類型),屬性的值類型(color)
                    SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
                    if (mSkinAttr != null) {
                        viewAttrs.add(mSkinAttr);
                    }
                } catch (NumberFormatException e) {
                    e.printStackTrace();
                } catch (NotFoundException e) {
                    e.printStackTrace();
                }
            }
        }
        //如果目前View需要換膚,那麼添加在mSkinItems中
        if(!ListUtils.isEmpty(viewAttrs)){
            SkinItem skinItem = new SkinItem();
            skinItem.view = view;
            skinItem.attrs = viewAttrs;

            mSkinItems.add(skinItem);
            //是否是使用外部皮膚進行換膚
            if(SkinManager.getInstance().isExternalSkin()){
                skinItem.apply();
            }
        }
    }
}      

資源擷取

通過目前的資源id,找到對應的資源name。再從皮膚包中找到該資源name所對應的資源id。

public class SkinManager implements ISkinLoader{
    /***部分代碼省略****/
    public int getColor(int resId){
        int originColor = context.getResources().getColor(resId);
        //是否沒有下載下傳皮膚或者目前使用預設皮膚
        if(mResources == null || isDefaultSkin){
            return originColor;
        }
        //根據resId值擷取對應的xml的的@+id的String類型的值
        String resName = context.getResources().getResourceEntryName(resId);
        //更具resName在皮膚包的mResources中擷取對應的resId
        int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
        int trueColor = 0;
        try{
            //根據resId擷取對應的資源value
            trueColor = mResources.getColor(trueResId);
        }catch(NotFoundException e){
            e.printStackTrace();
            trueColor = originColor;
        }
        
        return trueColor;
    }
    public Drawable getDrawable(int resId){...}
}      

其他

除此之外再增加以下對于皮膚的管理api(下載下傳、監聽回調、應用、取消、異常處理、擴充子產品等等)。

總結

換膚就是這麼簡單!!

文章到這裡就全部講述完啦,若有其他需要交流的可以留言哦!!