文章首發(fā)于imesong的個人網站
本文主要是在公司內部的一次分享會內容,基于 Android-Skin-Loader開源庫,實現(xiàn)一套完整的在線換膚方案。同時,也參考了很多網上總結的換膚思路,后面會給出主要的參考資料。
我們先看下Demo效果圖

為什么要做在線換膚?
皮膚模塊獨立,減小 Apk 包大小
資源文件在App大小中有很大的比重,特別是工具類的App,用戶有使用多套皮膚的需求,如果多套皮膚資源全部在 App 中,會極大的增加 App 的大小,不利于用戶下載以及渠道推廣。
技術方案通用,不局限于具體的 App
現(xiàn)在公司有十幾款移動端產品,但是還沒有App使用在線換膚或者使用類似插件式的換膚方案,如果能調研出一套完整的在線換膚方案,公司的各個 App 都可以使用。
服務端控制皮膚包,動態(tài)更新,滿足運營需求
皮膚包資源放在服務端,動態(tài)更新,可以滿足運營需求,發(fā)布不同主題皮膚資源包,避免通過發(fā)布版本迭代更新。
降低維護成本
通過把皮膚模塊獨立出來,減少主工程的邏輯,精簡主工程代碼,降低維護成本。
在線換膚的難點在哪里?
調研一個技術方案,有難點,有重點。當我們把這些重點難點攻克,方案的雛形基本就完成了。
如何加載皮膚資源文件
在線換膚,皮膚資源肯定不會在Apk內部,要怎么加載外部的皮膚資源呢?下載到本地,如何加載外部的資源文件呢?
我們先看下 Apk 的打包流程。

這里流程中,有兩個關鍵點
1.R文件的生成
R文件是一個Java文件,通過R文件我們就可以找到對應的資源。R文件就像一張映射表,幫助我們找到資源文件。
2.資源文件的打包生成
資源文件經過壓縮打包,生成 resources 文件,通過R文件找到里面保存的對映的資源文件
在 App 內部,我們一般通過下面代碼,獲取資源
context.getResource.getString(R.string.hello);
context.getResource.getColor(R.color.black);
context.getResource.getDrawable(R.drawable.splash);
這個時獲取 App 內部的資源,能我們家在皮膚資源什么思路嗎?加載外部資源的 Resources 能通過類似的思路嗎?
我們查看下 Resources 類的源碼,發(fā)現(xiàn) Resources 的構造函數(shù)
/**
* Create a new Resources object on top of an existing set of assets in an
* AssetManager.
*
* @param assets Previously created AssetManager.
* @param metrics Current display metrics to consider when
* selecting/computing resource values.
* @param config Desired device configuration to consider when
* selecting/computing resource values (optional).
*/
public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
this(assets, metrics, config, CompatibilityInfo.DEFAULT_COMPATIBILITY_INFO);
}
這里關鍵是第一個參數(shù)如何獲取,第二和第三個參數(shù)可以通過 Activity 獲取到。
我們再去看下 AssetManager 的代碼,同時會發(fā)現(xiàn)下面的這個
/**
* 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);
makeStringBlocks(mStringBlocks);
return res;
}
}
AssetManager 可以加載一個zip 格式的壓縮包,而 Apk 文件不就是一個 壓縮包嗎。我們通過反射的方法,拿到 AssetManager,加載 Apk 內部的資源,獲取到 Resources 對象,這樣再想辦法,把 R文件里面保存的ID獲取到,這樣既可以拿到對應的資源文件了。理論上我們的思路時成立的。
我們看下,如何通過代碼獲取 Resources 對象。
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);
Resources superRes = context.getResources();
Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());
標記需要換膚的 View
找到資源文件之后,我們要接著標記需要換膚的 View 。
找到需要換膚的 View
怎么尋找哪些是我們要關注的 View 呢? 我們還是重 View 的創(chuàng)建時機尋找機會。我們添加一個布局文件時,會使用 LayoutInflater的 Inflater方法,我們看下這個方法是怎么講一個View添加到Activity 中的。
LayoutInflater 中有個接口
public interface Factory {
/**
* Hook you can supply that is called when inflating from a LayoutInflater.
* You can use this to customize the tag names available in your XML
* layout files.
*
* <p>
* Note that it is good practice to prefix these custom names with your
* package (i.e., com.coolcompany.apps) to avoid conflicts with system
* names.
*
* @param name Tag name to be inflated.
* @param context The context the view is being created in.
* @param attrs Inflation attributes as specified in XML file.
*
* @return View Newly created view. Return null for the default
* behavior.
*/
public View onCreateView(String name, Context context, AttributeSet attrs);
}
根據(jù)這里的注釋描述,我們可以自己實現(xiàn)這個接口,在 onCreateView 方法中選擇我們需要標記的View,根據(jù) AttributeSet 值,過濾不需要關注的View。
標記 View 與對應的資源
我們在 View 創(chuàng)建時,通過過濾 Attribute 屬性,找到我們要標記的 View ,下面我們就把這些View的屬性記下來
for (int i = 0; i < attrs.getAttributeCount(); i++){
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
if(!AttrFactory.isSupportedAttr(attrName)){
continue;
}
if(attrValue.startsWith("@")){
try {
int id = Integer.parseInt(attrValue.substring(1));
String entryName = context.getResources().getResourceEntryName(id);
String typeName = context.getResources().getResourceTypeName(id);
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 和屬性值,一起封裝保存起來
if(!ListUtils.isEmpty(viewAttrs)){
SkinItem skinItem = new SkinItem();
skinItem.view = view;
skinItem.attrs = viewAttrs;
mSkinItems.add(skinItem);
if(SkinManager.getInstance().isExternalSkin()){
skinItem.apply();
}
}
及時更新 UI
由于我們把需要更新的View 以及屬性值都保存起來了,更新的時候只要把他們取出來遍歷一遍即可。
@Override
public void onThemeUpdate() {
if(!isResponseOnSkinChanging){
return;
}
mSkinInflaterFactory.applySkin();
}
//applySkin 的具體實現(xiàn)
public void applySkin(){
if(ListUtils.isEmpty(mSkinItems)){
return;
}
for(SkinItem si : mSkinItems){
if(si.view == null){
continue;
}
si.apply();
}
}
制作皮膚包
皮膚包制作相對簡單
1.創(chuàng)建獨立 model,包名自定義
2.添加資源文件到 model 中,不需要 java 代碼
3.運行 build.gradle 腳本,生成 xxx.skin 皮膚包
制定一套完整的在線換膚方案
到這里之后,還是沒看到在線換膚方案啊~說好的在線換膚方案呢?
1.將制作好的皮膚包上傳到服務端后臺
2.客戶端根據(jù)接口數(shù)據(jù),處理皮膚加載邏輯
模塊依賴關系

換膚方案的介紹基本完成,下面是一些參考資料和資源