Android換膚原理和Android-Skin-Loader框架解析
Android換膚技術已經是很久之前就已經被成熟使用的技術了,然而我最近才在學習和接觸熱修復的時候才看到。在看了一些換膚的方法之后,并且對市面上比較認可的Android-Skin-Loader換膚框架的源碼進行了分析總結。再次記錄一下祭奠自己逝去的時間。
換膚介紹
換膚本質上是對資源的一中替換包括、字體、顏色、背景、圖片、大小等等。當然這些我們都有成熟的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。
*@paramname 描述資源的名稱
*@paramdefType 資源的類型
*@paramdefPackage 包名
*
*@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 AssetManagerimplements 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 {@linkResources#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 AssetManagerimplements 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"));
/**
* 獲取文本資源
*/
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 Factory2extends 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 SkinApplicationextends 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
*@paramskinPackagePath path of skin apk
*@paramcallback 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 BaseFragmentActivityextends FragmentActivityimplements 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 SkinInflaterFactoryimplements 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 SkinInflaterFactoryimplements 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 SkinManagerimplements 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(下載、監聽回調、應用、取消、異常處理、擴展模塊等等)。
來自:http://dandanlove.com/2017/11/27/android-skin-changed/