話說什么是動態(tài)換膚?這里舉個例子:在APP中可以下載某一個皮膚包,然后應用起來整個APP的界面就發(fā)生了改變,諸如某些圖片,文字字體,文字顏色等等。
那么這種功能是怎么實現(xiàn)的呢?其實初步分析一把,應該就是在應用了皮膚包之后這些換膚了的控件的某些布局屬性發(fā)生了變化,比如width、height、src、background、textsize、textcolor等。話說回來,在沒有實現(xiàn)換膚功能之前我們的APP對控件進行屬性指定一般都是寫在屬性文件中,比如android:textColor="@color/textColorDefault",我們會在專門的color.xml文件中定義這個顏色屬性的具體value值,那么我們換膚時就應該是去替換color.xml文件中定義的textColorDefault這個屬性值。
現(xiàn)在開始分析Android默認是在什么時候開始加載視圖組件的。我們應該會聯(lián)想到Activity的onCreate()方法里面我們都要去調(diào)用setContentView(int id) 來指定當前Activity的布局文件,就像這樣:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
按照流程我們找到了這里:
@Override
public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);//這里實現(xiàn)view布局的加載
mOriginalWindowCallback.onContentChanged();
}
LayoutInflater的功能我們在fragment中應該很熟悉了,多說一句,在自定義viewGroup的時候我們也可以仿照這樣的寫法對自定義viewGroup指定默認的布局文件了。
好接下來我們順藤摸瓜來到了LayoutInflater.java里面看看inflate是怎么實現(xiàn)的:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
final String name = parser.getName();
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
return temp;
}
可以看到inflate會返回具體的View對象出去,那么我們的關注焦點就放在createViewFromTag中了。
/**
* Creates a view from a tag name using the supplied attribute set.
* <p>
* <strong>Note:</strong> Default visibility so the BridgeInflater can
* override it.
*
* @param parent the parent view, used to inflate layout params
* @param name the name of the XML tag used to define the view
* @param context the inflation context for the view, typically the
* {@code parent} or base layout inflater context
* @param attrs the attribute set for the XML tag used to define the view
* @param ignoreThemeAttr {@code true} to ignore the {@code android:theme}
* attribute (if set) for the view being inflated,
* {@code false} otherwise
*/
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
try {
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;
}
return view;
} catch (Exception e) {
}
}
這里我們先看看這幾個參數(shù)的意義,name指的是在layout.xml中給出的名稱,例如:
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="skinSelect"
android:text="個性換膚"/>
這里拿到的name值就是“Button”。再如:
<com.dongnao.dnskin.widget.MyTabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:tabIndicatorColor="@color/tabSelectedTextColor"
app:tabTextColor="@color/tab_selector"/>
這里拿到的name值就是“com.dongnao.dnskin.widget.MyTabLayout”,或者:
<android.support.v4.view.ViewPager
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
這里拿到的name值就是“android.support.v4.view.ViewPager”。
因此我們要明確一點,參數(shù)name可能是View控件的java全路徑名稱,也有可能不是,比如第一種情況的Button,但是這種情況只會出現(xiàn)在系統(tǒng)已有控件里面,它們的包名我們是可以大膽猜測出來的,無非就是在這么幾個包下面:
private static final String[]mClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
OK,分析完name參數(shù),我們在看看AttributeSet是個什么梗,其實源碼注釋已經(jīng)寫得很清楚了,就是xml文件中對這個View給出的屬性描述。
參數(shù)分析完了,我們看看方法體是怎么實現(xiàn)的。會發(fā)現(xiàn)生成View的時候會優(yōu)先從mFactory2中的onCreateView里面去獲取View對象,獲取到了就直接返回。所以我們是不是可以自己去實現(xiàn)這個mFactory2來代替系統(tǒng)生成View對象?因為生成View對象的工作由我們自己來完成的話我們就可以很輕松的獲取attrs參數(shù),并且根據(jù)attrs對象知道在layout.xml中對這個View做了哪些屬性描述,比如說拿到了background=“@drawable/bg_01”,當需要換膚的時候,我們就可以去皮膚包里面找到“@drawable/bg_01”這個資源,用來給這個View替換上去View.setBackground(...),那么我們的換膚功能不就實現(xiàn)了嗎?答案也確實是這樣做的。
接下來把精力放在怎么實現(xiàn)mFactory2上面,并且設置進入LayoutInflater中。前面我們知道Activity的setContentView()回去調(diào)用LayoutInflater.from(Context)拿到 LayoutInflater對象,代碼如下:
/**
* Obtains the LayoutInflater from the given context.
*/
public static LayoutInflater from(Context context) {
LayoutInflater LayoutInflater =
(LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
if (LayoutInflater == null) {
throw new AssertionError("LayoutInflater not found.");
}
return LayoutInflater;
}
通過源碼的注釋也可以看到每一個Activity會有自己的LayoutInflater對象,此外LayoutInflater還暴露了mFactory2的set方法提供給我們:
public void setFactory2(Factory2 factory) {
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
if (factory == null) {
throw new NullPointerException("Given factory can not be null");
}
mFactorySet = true;
if (mFactory == null) {
mFactory = mFactory2 = factory;
} else {
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}
因此,這個setter就可以在每個Activity的onCreate之前進行調(diào)用,達到我們想要的目的。到了這里,相信我們會想到使用ActivityLifecycleCallbacks回調(diào)來監(jiān)聽Activity的各個生命周期回調(diào),在onActivityCreated()進行mFactory2的初始化并且調(diào)用setter。
接下來,我們定義一個單例類SkinManager.java :
public class SkinManager extends Observable {
Application application;
private static SkinManager instance;
/**
* 客戶端程序在application的onCreate()后調(diào)用.
* @param application
*/
public static void init(Application application) {
synchronized (SkinManager.class) {
if (null == instance) {
instance = new SkinManager(application);
}
}
}
public static SkinManager getInstance() {
return instance;
}
private SkinManager(Application application) {
this.application = application;
application.registerActivityLifecycleCallbacks(new SkinActivityLifecycleCallbacks());
}
SkinActivityLifecycleCallbacks.java如下:
public class SkinActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
LayoutInflater layoutInflater = LayoutInflater.from(activity);
try {
Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
field.setAccessible(true);
field.setBoolean(layoutInflater, false);
} catch (Exception e) {
e.printStackTrace();
}
SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory(activity,typeface);
LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutFactory);
}
...
}
因為setFactory2()中有一個mFactorySet布爾類型的判斷,我們使用了反射對mFactorySet置為true。
我們只需要在自定義application的onCreate()后面調(diào)用SkinManager.init()就完成了所有Activity的mFactory2設置。
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
SkinManager.init(this);
}
OK,到了這一步準備工作就做完了,我們重心放入自定義Factory2的實現(xiàn)中來??纯碏actory2是個什么東西:
public interface Factory2 extends Factory {
/**
* Version of {@link #onCreateView(String, Context, AttributeSet)}
* that also supplies the parent that the view created view will be
* placed in.
*
* @param parent The parent that the created view will be placed
* in; <em>note that this may be null</em>.
* @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(View parent, String name, Context context, AttributeSet attrs);
}
它是一個接口,聲明了一個創(chuàng)建View的函數(shù)等待實現(xiàn)類去實現(xiàn)。我們的實現(xiàn)類如下:
public class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
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."
};
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = createViewFromTag(name, context, attrs);
return view;
}
private View createViewFromTag(String name, Context context, AttributeSet attrs) {
//包含了. 自定義控件
if (name.contains(".")) {
return createView(name,context,attrs);
}
for (String tag : mClassPrefixList) {
View v = createView(tag + name, context, attrs);
if (null == v)
continue;
return v;
}
return null;
}
private View createView(String name, Context context, AttributeSet attrs) {
Constructor<? extends View> constructor = sConstructorMap.get(name);
if (null == constructor) {
try {
Class<? extends View> aClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
constructor = aClass.getConstructor(Context.class, AttributeSet.class);
sConstructorMap.put(name, constructor);
} catch (Exception e) {
}
}
if (null != constructor) {
try {
return constructor.newInstance(context, attrs);
} catch (Exception e) {
}
}
return null;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return null;
}
}
這里的實現(xiàn)其實也很簡單,就是我們獲取View的java全名稱,然后通過反射機制獲取View的構(gòu)造方法進行實例化再返回。當然,我們這里還做了一個靜態(tài)的map來緩存View的構(gòu)造方法,可以優(yōu)化一定的性能,畢竟反射多了總是不好的對吧(其實你仔細看了LayoutInflater的源碼,它就是這么做的,我們這里借鑒一下)。
OK,按照前面給出的思路,我們自己構(gòu)建mFactory2,代替系統(tǒng)來創(chuàng)建View對象,接下來還差一個步驟,就是通過attrs參數(shù)知道這個View在xml中被哪些屬性描述了,我們需要一個機制來記錄這個View被描述過了的并且可能會被皮膚包替換資源的屬性名稱還有默認的資源Id,在換膚時就去這些記錄里面查找View及它的換膚屬性的名稱和資源Id,拿到默認資源Id后就可以知道這個資源的類型和名稱,比如@string/s_name、@color/co_default_bg,然后拿著資源類型和名稱去皮膚包中查找同類型同名稱的資源,然后根據(jù)屬性名稱給這個View更改相應的表現(xiàn)。比如描述屬性名稱和資源類型名稱是textColor=“@color/default_tx_color”,在皮膚包中找到“@color/default_tx_color”這個資源,給View.setTextColor(皮膚包中找到的資源),如果屬性名稱是background,那么就是View.setBackground(皮膚包中找到的資源),這樣就達到了換膚效果。
接下來就是怎么去實現(xiàn)這個記錄View及它的換膚屬性和資源名稱的機制了。
我們設計一套數(shù)據(jù)結(jié)構(gòu)來記錄這種關系。

其實可以在自定義Factory2返回View對象之前做這些工作,比如交給SkinAttribute對象去做。SkinAttribute以及SkinView、SkinPair代碼如下:
public class SkinAttribute {
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");
}
private List<SkinView> skinViews = new ArrayList<>();
/**
* 篩選符合屬性的view
*
* @param view
* @param attrs
*/
public void load(View view, AttributeSet attrs) {
List<SkinPair> skinPairs = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//獲得屬性名
String attributeName = attrs.getAttributeName(i);
if (mAttributes.contains(attributeName)) {
String attributeValue = attrs.getAttributeValue(i);
//寫死了 不管了
if (attributeValue.startsWith("#")) {
continue;
}
//資源id
int resId = 0;
if (attributeValue.startsWith("?")) {//?開頭, "?colorAccess" 對應主題中的屬性名稱id
int attrId = Integer.parseInt(attributeValue.substring(1));//屬性id
//獲得主題style中對應attr的資源id值
resId = SkinUtils.getResId(view.getContext(), new int[]{attrId})[0];
} else {//@開頭 "@ID"
resId = Integer.parseInt(attributeValue.substring(1));
}
if (resId != 0) {
//可以替換的屬性
SkinPair skinPair = new SkinPair(attributeName, resId);
skinPairs.add(skinPair);
}
}
}
if (!skinPairs.isEmpty() || view instanceof TextView) {
SkinView skinView = new SkinView(view, skinPairs);
skinViews.add(skinView);
}
}
static class SkinView {
View view;
/**
* 當前view支持換膚特性的屬性與id鍵值對列表
*/
List<SkinPair> skinPairs;
public SkinView(View view, List<SkinPair> skinPairs) {
this.view = view;
this.skinPairs = skinPairs;
}
}
static class SkinPair {
/**
* 屬性名稱,例如:background,src,textColor等
*/
String attributeName;
/**
* 資源ID值
*/
int resId;
public SkinPair(String attributeName, int resId) {
this.attributeName = attributeName;
this.resId = resId;
}
}
}
上述代碼中有一處需要清楚的是:String attributeValue = attrs.getAttributeValue() 返回的是一個字符串,例如textColor=“#ffffff”,那么attributeValue = “#ffffff”,textColor=“@color/default_color”,那么attributeValue = “@12345678”,這里的“12345678”指的就是“@color/default_color”對應的資源ID,類似的還有“@drawable/default_bg”等等。當然,還有一種情況就是textColor=“?colorAccess”,雖然程序最終引用的資源是style.xml中定義的屬性值“colorAccent”指向的“@color/colorAccent”,
<style name="BaseTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorAccent">@color/colorAccent</item>
</style>
這個時候attributeValue = “?12121212”,但是“12121212”并不是“@color/colorAccent”的資源ID,而是style屬性“colorAccent”代表的ID,因此對待“?12121212”這樣的情況我們還需要再去style.xml中去查找真正引用的資源ID。具體做法如下:
public class SkinUtils {
public static int[] getResId(Context context, int[] attrs) {
int[] resIds = new int[attrs.length];
TypedArray typedArray = context.obtainStyledAttributes(attrs);
for (int i = 0; i < typedArray.length(); i++) {
resIds[i] = typedArray.getResourceId(i, 0);
}
typedArray.recycle();
return resIds;
}
}
到了這里,相信你也就知道了SkinLayoutFactory該怎么改造了:
public class SkinLayoutFactory implements LayoutInflater.Factory2{
......
private SkinAttribute skinAttribute;
public SkinLayoutFactory() {
skinAttribute = new SkinAttribute();
}
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = createViewFromTag(name, context, attrs);
skinAttribute.load(view,attrs);//在返回view之前維護我們需要的 屬性-資源 關系數(shù)據(jù)結(jié)構(gòu)
return view;
}
......
}
OK,現(xiàn)在需要換膚控件的資源信息也采集到了,接下來就是怎么去實現(xiàn)換膚了。
換膚之前我們要清楚什么是皮膚包,又該怎么把皮膚包加載到系統(tǒng)里面供我們獲取資源并使用。
皮膚包其實就是一個apk文件,只不過內(nèi)部只包含資源文件,我們的皮膚包目錄結(jié)構(gòu)如下:

接下來,我們該如何把一個皮膚包加載進項目中,并且根據(jù)資源類型和名稱來獲取指定資源的Id呢?
首先是將皮膚包加載進入項目,我們會用到AssetManager這個工具:
/**
* 加載皮膚包 并 立即通知觀察者更新
*
* @param path 皮膚包路徑
*/
public void loadSkin(String path) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
// 添加資源進入資源管理器
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String
.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, path);
//app默認資源
Resources resources = application.getResources();
//皮膚包資源
Resources skinResource = new Resources(assetManager, resources.getDisplayMetrics(),
resources.getConfiguration());
//獲取外部Apk(皮膚包) 包名
PackageManager mPm = application.getPackageManager();
PackageInfo info = mPm.getPackageArchiveInfo(path, PackageManager
.GET_ACTIVITIES);
String packageName = info.packageName;
} catch (Exception e) {
e.printStackTrace();
}
}
}
上述代碼我們傳入皮膚包的路徑,通過AssetManager獲取到Resource對象,其實到這一步就已經(jīng)將皮膚包的資源文件加載進來了。
那么加載到了皮膚包的Resource對象,我們該如何通過APP程序默認的一個資源id去拿到在皮膚包中同類型同名稱的這個資源的id呢?
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;
}
這個方法其實就是將默認資源id轉(zhuǎn)化成皮膚包中對應資源的id,獲取到了id我們就可以通過Resources.getXXX(int id)來拿到想要的資源了。
到了這個時候,相信我們都有了一個完整的換膚思路了:
①重寫Factory2,代替系統(tǒng)創(chuàng)建View對象,在這期間記錄下 需要換膚控件的 屬性名-資源ID 的集合。
②通過AssetManager加載外部的皮膚包資源Resource,通過默認的資源ID找到在皮膚包中對應的資源ID,通過屬性名稱去動態(tài)修改View的具體表現(xiàn)。
③開始換膚時,我們可以使用觀察者模式來通知所有還未銷毀的Activity持有的 SkinLayoutFactory(作為觀察者),讓SkinLayoutFactory去遍歷其下面的所有 SkinView來完成應用換膚資源的工作。
/**
* 對當前view進行支持換膚的屬性進行配置,應用原生或者皮膚包的資源.
* @param typeface
*/
public void applySkin() {
for (SkinPair skinPair : skinPairs) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
case "background":
Object background = SkinResources.getInstance().getBackground(skinPair
.resId);
//Color
if (background instanceof Integer) {
view.setBackgroundColor((Integer) background);
} else {
ViewCompat.setBackground(view, (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展示調(diào)整的邏輯。
我們再來看下SkinResources.java的代碼:
public class SkinResources {
private static SkinResources instance;
private Resources mSkinResources;
private String mSkinPkgName;
private boolean isDefaultSkin = true;
private Resources mAppResources;
private SkinResources(Context context) {
mAppResources = context.getResources();
}
public static void init(Context context) {
if (instance == null) {
synchronized (SkinResources.class) {
if (instance == null) {
instance = new SkinResources(context);
}
}
}
}
public static SkinResources getInstance() {
return instance;
}
public void reset() {
mSkinResources = null;
mSkinPkgName = "";
isDefaultSkin = true;
}
public void applySkin(Resources resources, String pkgName) {
mSkinResources = resources;
mSkinPkgName = pkgName;
//是否使用默認皮膚
isDefaultSkin = TextUtils.isEmpty(pkgName) || resources == null;
}
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);
}
public ColorStateList getColorStateList(int resId) {
if (isDefaultSkin) {
return mAppResources.getColorStateList(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getColorStateList(resId);
}
return mSkinResources.getColorStateList(skinId);
}
public Drawable getDrawable(int resId) {
//如果有皮膚 isDefaultSkin false 沒有就是true
if (isDefaultSkin) {
return mAppResources.getDrawable(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getDrawable(resId);
}
return mSkinResources.getDrawable(skinId);
}
/**
* 可能是Color 也可能是drawable
*
* @return
*/
public Object getBackground(int resId) {
String resourceTypeName = mAppResources.getResourceTypeName(resId);
if (resourceTypeName.equals("color")) {
return getColor(resId);
} else {
// drawable
return getDrawable(resId);
}
}
public String getString(int resId) {
try {
if (isDefaultSkin) {
return mAppResources.getString(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getString(skinId);
}
return mSkinResources.getString(skinId);
} catch (Resources.NotFoundException e) {
}
return null;
}
}
OK,整個換膚原理基本就講完了,當然還有字體的動態(tài)全局及單個切換,自定義view的自定義屬性切換,某些控件加載時序問題導致無法換膚等問題,后面繼續(xù)補充。
附上源碼地址
鏈接:https://share.weiyun.com/51Q5YxV