思路整體結構
方案及輪子
- 內部資源載入方案
- 通過在BaseActivity中setTheme
- 不好實時的重新整理,需要重新建立頁面
- 存在需要解決哪些Vew需要重新整理的問題
- 自定義View
- MultipleTheme
- 通過自定義View配合setTheme後立即重新整理資源。
- 需要替換所有需要換膚的view
- 自定義xml屬性,Java中繫結view
- Colorful
- 首先通過在java程式碼中新增view
- 然後setTheme設定當前頁面主題
- 最後通過內部引用的上下文getTheme遍歷view來修改資源
- 動態資源載入方案
- Android-Skin-Loader
- ThemeSkinning(是上面那個框架的衍生,整篇就是研究的這框架)
- resource替換:通過單獨打包一個資源apk,只用來訪問資源,資源名得與本身對應
- 無需關心皮膚多少,可下載,等等
- 準備採用該方案
採用方案的技術點
- 獲取皮膚資源包apk的資源
- 自定義xml屬性,用來標記需要換膚的view
- 獲取並相應有換膚需求的佈局
- 其他
- 擴充套件可自行新增所支援換膚的屬性
- 改變狀態列顏色
- 改變字型
採用方案的實現過程
載入皮膚apk獲取裡面的資源(為了得到皮膚apk Resources物件)
下面所有的程式碼位置,包括處理一些特殊問題的方案等等!
https://github.com/xujiaji/ThemeSkinning
通過皮膚apk的全路徑,可知道其包名(需要用包名來獲取它的資源id)
skinPkgPath
是apk的全路徑,通過mInfo.packageName
就可以得到包名- 程式碼位置:SkinManager.java
PackageManager mPm = context.getPackageManager();
PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
skinPackageName = mInfo.packageName;
複製程式碼
通過反射新增路徑可以建立皮膚apk的AssetManager物件
skinPkgPath
是apk的全路徑,新增路徑的方法是AssetManager裡一個隱藏的方法通過反射可以設定。- 此時還可以用
assetManager
來訪問apk裡assets目錄的資源。 - 想想如果更換的資源是放在assets目錄下的,那麼我們可以在這裡動動手腳。
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);
複製程式碼
建立皮膚apk的資源物件
- 獲取當前的app的Resources,主要是為了建立apk的Resources
Resources superRes = context.getResources();
Resources skinResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
複製程式碼
當要通過資源id獲取顏色的時候
- 先獲取內建的顏色
int originColor = ContextCompat.getColor(context, resId);
- 如果沒有外接皮膚apk資源或就用預設資源的情況下直接返回內建顏色
- 通過
context.getResources().getResourceEntryName(resId);
獲取資源id獲取它的名字 - 通過
mResources.getIdentifier(resName, "color", skinPackageName)
得到皮膚apk中該資源id。(resName:就是資源名字;skinPackegeName就是皮膚apk的包名) - 如果沒有獲取到皮膚apk中資源id(也就是等於0)返回原來的顏色,否則返回
mResources.getColor(trueResId)
通過getIdentifier
方法可以通過名字來獲取id,比如將第二個引數修改為layout
、mipmap
、drawable
或string
就是通過資源名字獲取對應layout目錄
、mipmap目錄
、drawable目錄
或string檔案
裡的資源id
public int getColor(int resId) {
int originColor = ContextCompat.getColor(context, resId);
if (mResources == null || isDefaultSkin) {
return originColor;
}
String resName = context.getResources().getResourceEntryName(resId);
int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
int trueColor;
if (trueResId == 0) {
trueColor = originColor;
} else {
trueColor = mResources.getColor(trueResId);
}
return trueColor;
}
複製程式碼
當要通過資源id獲取圖片的時候
- 和上面獲取顏色是差不多的
- 只是在圖片在
drawable
目錄還是mipmap
目錄進行了判斷
public Drawable getDrawable(int resId) {
Drawable originDrawable = ContextCompat.getDrawable(context, resId);
if (mResources == null || isDefaultSkin) {
return originDrawable;
}
String resName = context.getResources().getResourceEntryName(resId);
int trueResId = mResources.getIdentifier(resName, "drawable", skinPackageName);
Drawable trueDrawable;
if (trueResId == 0) {
trueResId = mResources.getIdentifier(resName, "mipmap", skinPackageName);
}
if (trueResId == 0) {
trueDrawable = originDrawable;
} else {
if (android.os.Build.VERSION.SDK_INT < 22) {
trueDrawable = mResources.getDrawable(trueResId);
} else {
trueDrawable = mResources.getDrawable(trueResId, null);
}
}
return trueDrawable;
}
複製程式碼
對所有view進行攔截處理
- 自己實現
LayoutInflater.Factory2
介面來替換系統預設的
那麼如何替換呢?
- 就這樣通過在Activity方法中super.onCreate之前呼叫
- 程式碼位置:SkinBaseActivity.java
@Override
protected void onCreate(Bundle savedInstanceState) {
mSkinInflaterFactory = new SkinInflaterFactory(this);//自定義的Factory
LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
super.onCreate(savedInstanceState);
}
複製程式碼
我們使用的Activity一般是
AppCompatActivity
在裡面的onCreate方法中也有對其的設定和初始化,但是setFactory方法只能被呼叫一次,導致預設的一些初始化操作沒有被呼叫,這麼操作?
- 這是實現了
LayoutInflater.Factory2
介面的類,看onCreateView
方法中。在進行其他操作前呼叫delegate.createView(parent, name, context, attrs)
處理系統的那一套邏輯。 attrs.getAttributeBooleanValue
獲取當前view是否是可換膚的,第一個引數是xml名字空間,第二個引數是屬性名,第三個引數是預設值。這裡相當於是attrs.getAttributeBooleanValue("http://schemas.android.com/android/skin", "enable", false)
- 程式碼位置:SkinInflaterFactory.java
public class SkinInflaterFactory implements LayoutInflater.Factory2 {
private AppCompatActivity mAppCompatActivity;
public SkinInflaterFactory(AppCompatActivity appCompatActivity) {
this.mAppCompatActivity = appCompatActivity;
}
@Override
public View onCreateView(String s, Context context, AttributeSet attributeSet) {
return null;
}
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);//是否是可換膚的view
AppCompatDelegate delegate = mAppCompatActivity.getDelegate();
View view = delegate.createView(parent, name, context, attrs);//處理系統邏輯
if (view instanceof TextView && SkinConfig.isCanChangeFont()) {
TextViewRepository.add(mAppCompatActivity, (TextView) view);
}
if (isSkinEnable || SkinConfig.isGlobalSkinApply()) {
if (view == null) {
view = ViewProducer.createViewFromTag(context, name, attrs);
}
if (view == null) {
return null;
}
parseSkinAttr(context, attrs, view);
}
return view;
}
}
複製程式碼
當內部的初始化操作完成後,如果判斷沒有建立好view,則需要我們自己去建立view
- 看上一步是通過
ViewProducer.createViewFromTag(context, name, attrs)
來建立 - 那麼直接來看一下這個類
ViewProducer
,原理功能請看程式碼註釋 - 在AppCompatViewInflater中你可以看到相同的程式碼
- 程式碼位置:ViewProducer.java
class ViewProducer {
//該處定義的是view構造方法的引數,也就是View兩個引數的構造方法:public View(Context context, AttributeSet attrs)
private static final Object[] mConstructorArgs = new Object[2];
//存放反射得到的構造器
private static final Map<String, Constructor<? extends View>> sConstructorMap
= new ArrayMap<>();
//這是View兩個引數的構造器所對應的兩個引數
private static final Class<?>[] sConstructorSignature = new Class[]{
Context.class, AttributeSet.class};
//如果是系統的View或ViewGroup在xml中並不是全路徑的,通過反射來例項化是需要全路徑的,這裡列出來它們可能出現的位置
private static final String[] sClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
static View createViewFromTag(Context context, String name, AttributeSet attrs) {
if (name.equals("view")) {//如果是view標籤,則獲取裡面的class屬性(該View的全名)
name = attrs.getAttributeValue(null, "class");
}
try {
//需要傳入構造器的兩個引數的值
mConstructorArgs[0] = context;
mConstructorArgs[1] = attrs;
if (-1 == name.indexOf('.')) {//如果不包含小點,則是內部View
for (int i = 0; i < sClassPrefixList.length; i++) {//由於不知道View具體在哪個路徑,所以通過迴圈所有路徑,直到能例項化或結束
final View view = createView(context, name, sClassPrefixList[i]);
if (view != null) {
return view;
}
}
return null;
} else {//否則就是自定義View
return createView(context, name, null);
}
} catch (Exception e) {
//如果丟擲異常,則返回null,讓LayoutInflater自己去例項化
return null;
} finally {
// 清空當前資料,避免和下次資料混在一起
mConstructorArgs[0] = null;
mConstructorArgs[1] = null;
}
}
private static View createView(Context context, String name, String prefix)
throws ClassNotFoundException, InflateException {
//先從快取中獲取當前類的構造器
Constructor<? extends View> constructor = sConstructorMap.get(name);
try {
if (constructor == null) {
// 如果快取中沒有建立過,則嘗試去建立這個構造器。通過類載入器載入這個類,如果是系統內部View由於不是全路徑的,則前面加上
Class<? extends View> clazz = context.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);
//獲取構造器
constructor = clazz.getConstructor(sConstructorSignature);
//將構造器放入快取
sConstructorMap.put(name, constructor);
}
//設定為無障礙(設定後即使是私有方法和成員變數都可訪問和修改,除了final修飾的)
constructor.setAccessible(true);
//例項化
return constructor.newInstance(mConstructorArgs);
} catch (Exception e) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
// try
return null;
}
}
}
複製程式碼
- 當然還有另外的方式來建立,就是直接用LayoutInflater內部的那一套
- 將
view = ViewProducer.createViewFromTag(context, name, attrs);
刪除,換成下方程式碼: - 程式碼位置:SkinInflaterFactory.java
LayoutInflater inflater = mAppCompatActivity.getLayoutInflater();
if (-1 == name.indexOf('.'))//如果為系統內部的View則,通過迴圈這幾個地方來例項化View,道理跟上面ViewProducer裡面一樣
{
for (String prefix : sClassPrefixList)
{
try
{
view = inflater.createView(name, prefix, attrs);
} catch (ClassNotFoundException e)
{
e.printStackTrace();
}
if (view != null) break;
}
} else
{
try
{
view = inflater.createView(name, null, attrs);
} catch (ClassNotFoundException e)
{
e.printStackTrace();
}
}
複製程式碼
sClassPrefixList
的定義
private static final String[] sClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
複製程式碼
最後是最終的攔截獲取需要換膚的View的部分,也就是上面
SkinInflaterFactory
類的onCreateView
最後呼叫的parseSkinAttr
方法
- 定義類一個成員來儲存所有需要換膚的View, SkinItem裡面的邏輯就是定義了設定換膚的方法。如:View的setBackgroundColor或setColor等設定換膚就是靠它。
private Map<View, SkinItem> mSkinItemMap = new HashMap<>();
複製程式碼
- SkinAttr: 需要換膚處理的xml屬性,如何定義請參照官方文件:https://github.com/burgessjp/ThemeSkinning
private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
//儲存需要換膚處理的xml屬性
List<SkinAttr> viewAttrs = new ArrayList<>();
//變數該view的所有屬性
for (int i = 0; i < attrs.getAttributeCount(); i++) {
String attrName = attrs.getAttributeName(i);//獲取屬性名
String attrValue = attrs.getAttributeValue(i);//獲取屬性值
//如果屬性是style,例如xml中設定:style="@style/test_style"
if ("style".equals(attrName)) {
//可換膚的屬性
int[] skinAttrs = new int[]{android.R.attr.textColor, android.R.attr.background};
//經常在自定義View時,構造方法中獲取屬性值的時候使用到。
//這裡通過傳入skinAttrs,TypeArray中將會包含這兩個屬性和值,如果style裡沒有那就沒有 - -
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, skinAttrs, 0, 0);
//獲取屬性對應資源的id,第一個引數這裡對應下標的就是上面skinAttrs陣列裡定義的下標,第二個引數是沒有獲取到的預設值
int textColorId = a.getResourceId(0, -1);
int backgroundId = a.getResourceId(1, -1);
if (textColorId != -1) {//如果有顏色屬性
//<style name="test_style">
//<item name="android:textColor">@color/colorAccent</item>
//<item name="android:background">@color/colorPrimary</item>
//</style>
//以上邊的參照來看
//entryName就是colorAccent
String entryName = context.getResources().getResourceEntryName(textColorId);
//typeName就是color
String typeName = context.getResources().getResourceTypeName(textColorId);
//建立一換膚屬性實力類來儲存這些資訊
SkinAttr skinAttr = AttrFactory.get("textColor", textColorId, entryName, typeName);
if (skinAttr != null) {
viewAttrs.add(skinAttr);
}
}
if (backgroundId != -1) {//如果有背景屬性
String entryName = context.getResources().getResourceEntryName(backgroundId);
String typeName = context.getResources().getResourceTypeName(backgroundId);
SkinAttr skinAttr = AttrFactory.get("background", backgroundId, entryName, typeName);
if (skinAttr != null) {
viewAttrs.add(skinAttr);
}
}
a.recycle();
continue;
}
//判斷是否是支援的屬性,並且值是引用的,如:@color/red
if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
try {
//去掉屬性值前面的“@”則為id
int id = Integer.parseInt(attrValue.substring(1));
if (id == 0) {
continue;
}
//資源名字,如:text_color_selector
String entryName = context.getResources().getResourceEntryName(id);
//資源型別,如:color、drawable
String typeName = context.getResources().getResourceTypeName(id);
SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
if (mSkinAttr != null) {
viewAttrs.add(mSkinAttr);
}
} catch (NumberFormatException e) {
SkinL.e(TAG, e.toString());
}
}
}
//是否有需要換膚的屬性?
if (!SkinListUtils.isEmpty(viewAttrs)) {
SkinItem skinItem = new SkinItem();
skinItem.view = view;
skinItem.attrs = viewAttrs;
mSkinItemMap.put(skinItem.view, skinItem);
//是否換膚
if (SkinManager.getInstance().isExternalSkin() ||
SkinManager.getInstance().isNightMode()) {//如果當前皮膚來自於外部或者是處於夜間模式
skinItem.apply();//應用於這個view
}
}
}
複製程式碼
採用方案的注意事項和疑問
- 可能系統會更改相關方法,但好處大於弊端
- 外掛化也是外接apk來載入,如何做到呢?
- 佔時不去研究
- 皮膚從網路上下載到哪個目錄?如何斷定皮膚已經下載?
- 可以通過
SkinFileUtils
工具類呼叫getSkinDir
方法獲取皮膚的快取目錄 - 下載的時候可以直接下載到這個目錄
- 有沒有某個皮膚就判斷該資料夾下有沒有這個檔案了
- 可以通過
- 如何不打包之前可以直接預覽?
- 想要能在打包前提前預覽效果,而不每次想看一看效果就要打一個apk包
- 首先,大家都應該知道分渠道的概念。通過分渠道打包,因為我們能把資源也分成不同渠道的,執行不同渠道,所得到的資源是不一樣的。
- 然後,我們在:
專案目錄\app\src
,建立一個和渠道相同名字的目錄。比如說有個red
渠道。 - 最後,我們選編譯的渠道為red,然後直接執行就可以看到效果了。如果可以直接把res拷貝到皮膚專案打包就行了。
- 換膚對應的屬性需要是View提供了set方法的的屬性!
- 如果沒有提供則不能在java程式碼中設定值
- 如果是自定義View那麼就新增對應方法
- 如果是系統或類庫View,額(⊙o⊙)…
- 換膚的屬性值需要是@開頭的資料引用,如:@color/red
- 原因是因為固定的值一般不可能是需要換膚的屬性,在
SkinInfaterFactory
的方法parseSkinAttr
中有這樣一句來進行過濾沒有帶@的屬性值: - 但此時,正好有一個自定義View沒有按照常路出牌,它的值就是圖片名字沒有型別沒有引用,通過java程式碼
context.getResources().getIdentifier(name, "mipmap", context.getPackageName())
來獲取圖片資源(參考這奇葩方式的庫)。但由於這個屬性是需要換膚更換的屬性,於是沒辦法,專門為這兩個屬性在SkinInfaterFactory
的parseSkinAttr
方法中寫了個判斷參考這程式碼
- 原因是因為固定的值一般不可能是需要換膚的屬性,在
其他參考
- Android主題換膚 無縫切換 (主要參考物件,用的也是他修改
Android-Skin-Loader
後的框架ThemeSkinning
) - Android換膚技術總結
- Android apk動態載入機制的研究
涉及及其延生
- 外掛化開發,既然能這樣獲取資源,也能獲取class檔案
- 通過對view的攔截可以把某個控制元件整體替換掉。 比如AppCompatActivity將TextView偷偷替換成了AppCompatTextView等等。
其他一些幫助資訊:
上面對應的程式碼片段都有對應路徑哦!
這篇文章的全部程式碼,測試專案位置:https://github.com/xujiaji/ThemeSkinning
測試專案中的首頁底部導航測試和修改位置:https://github.com/xujiaji/FlycoTabLayout
下面這張Gif圖片是測試專案執行的效果圖: