實現的效果圖
動態換膚一般實現的原理
- 對頁面需要換膚的View進行標記
- 在
Activity#setContentView()
加載view時獲取到標記的view(後面會說是要怎麼獲取到) - 創建一個Library項目製作我們的皮膚包(res下的資源名稱需要與app使用的一致,換膚就是通過使用的資源名稱去
皮膚包
里加載相同名字的資源) - 創建皮膚包對應的
Resources
對象(用於加載皮膚包內的資源) - 點擊換膚將我們標記的View的一些屬性上設置的值修改爲皮膚包裏的值,這樣就達到換膚的效果
一、對頁面需要換膚的View進行標記
這一步是相對簡單的,只要自定義一個屬性即可;在獲取View的時候判斷有無這個屬性 有就將這個view存起來
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:skin="http://schemas.android.com/apk/azhon-skin"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/bg_1"
android:text="我是一個TextView"
android:textColor="@color/title_1"
android:textSize="16sp"
skin:enable="true" />
<Button
android:id="@+id/btn_dark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@color/bg_2"
android:text="@string/btn_text"
android:textColor="@color/title_2"
skin:enable="true" />
<Button
android:id="@+id/btn_default"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@color/bg_3"
android:text="@string/btn_reset_text"
android:textColor="@color/title_3" />
</LinearLayout>
skin:enable="true"
這個就是自定的一個屬性取值爲boolean
,如果爲true
就表示在換膚的時候需要去皮膚包加載對應的資源
二、獲取在佈局標記好的View
這裏使用的是自定義佈局加載器LayoutInflater
的LayoutInflater.Factory2
來監聽View的創建;下面我們來通過閱讀源碼來具體說一下爲什麼使用的這個:
- 查看AppCompatActivity的setContentView()方法
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
- 接着繼續調用了getDelegate()的setContentView()方法
// AppCompatActivity.java
@Override
public void setContentView(@LayoutRes int layoutResID) {
getDelegate().setContentView(layoutResID);
}
getDelegate()
獲取到的是AppCompatDelegate
這個抽象類的實現類,而他的實現類就只有一個AppCompatDelegateImpl
- 接着調用了AppCompatDelegateImpl的setContentView()
// AppCompatDelegateImpl.java
@Override
public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
//重點就是這樣代碼,通過佈局加載器加載xml文件
LayoutInflater.from(mContext).inflate(resId, contentParent);
mAppCompatWindowCallback.getWrapped().onContentChanged();
}
閱讀到這裏就可以看到有用的代碼了
LayoutInflater.from(mContext).inflate(resId, contentParent)
加載我們的xml佈局文件,他傳入了我們的佈局資源id
和android.R.id.content
這個ViewGroup;有了解過Activity的佈局層次結構的同學肯定就知道是什麼了。
- 接着往下看LayoutInflater的inflate()方法
// LayoutInflater.java
//No.1
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
return inflate(resource, root, root != null);
}
//No.2 接着調用了
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
final Resources res = getContext().getResources();
if (DEBUG) {
Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
+ Integer.toHexString(resource) + ")");
}
final XmlResourceParser parser = res.getLayout(resource);
try {
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
//No.3 接着調用了
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");
// 省略若干源代碼....
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
// 省略若干源代碼....
}
}
}
調用
inflate()
最終調用了createViewFromTag()
這個方法根據佈局寫的代碼開始創建對應的View實體,繼續向下查看createViewFromTag()
的代碼
// LayoutInflater.java
// No.1
private View createViewFromTag(View parent, String name, Context context, AttributeSet attrs) {
return createViewFromTag(parent, name, context, attrs, false);
}
// No.2
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;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (InflateException e) {
throw e;
}
// 省略若干源代碼....
}
代碼查看到這裏終於看到了開頭所說的Factory
這個東西,上面代碼最終通過調用onCreateView()
來創建view;所以我們只需要對LayoutInflater
設置一個Factory
即可。
先來看看設置setFactory()
的方法
// LayoutInflater.java
public void setFactory(Factory 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 = factory;
} else {
mFactory = new FactoryMerger(factory, null, mFactory, mFactory2);
}
}
- 可以很清楚的看到,如果我們調用了這個方法那麼肯定會拋出一個異常
IllegalStateException ,A factory has already been set on this LayoutInflater
,所以設置之前我們需要通過反射將mFactorySet
這個變量置爲false
需要注意的一點:
既然是干預View的加載創建,那肯定設置Factory
需要在LayoutInflater
實例創建之後,在加載創建View之前;而Activity是通過setContentView()
加載View所以設置Factory
需要在setContentView()
之前;這裏可以通過Application
設置Activity的生命週期監聽器,即registerActivityLifecycleCallbacks()
上面bb了一堆現在來上代碼了
public class App extends Application {
@Override
public void onCreate() {
super.onCreate();
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
setFactory(activity);
}
@Override
public void onActivityStarted(Activity activity) {
}
@Override
public void onActivityResumed(Activity activity) {
}
@Override
public void onActivityPaused(Activity activity) {
}
@Override
public void onActivityStopped(Activity activity) {
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
}
@Override
public void onActivityDestroyed(Activity activity) {
}
});
}
}
- 創建
SkinFactory.java
public final class SkinFactory implements LayoutInflater.Factory2 {
private static final String TAG = "SkinFactory";
private static final String[] classPrefixList = {"android.view.", "android.widget.", "android.webkit."};
private static final String NAME_SPACE = "http://schemas.android.com/apk/azhon-skin";
private static final String ATTRIBUTE = "enable";
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
//找到佈局使用屬性(skin:enable="true")標記需要換膚的view
boolean isSkinView = attrs.getAttributeBooleanValue(NAME_SPACE, ATTRIBUTE, false);
//如果不是換膚的View就直接不處理
if (!isSkinView) return null;
View view = null;
//name不包含.的說明是系統的控件
if (-1 == name.indexOf('.')) {
for (String prefix : classPrefixList) {
view = createView(name, prefix, context, attrs);
if (view != null) break;
}
} else {
view = createView(name, null, context, attrs);
}
LogUtil.d(TAG, "onCreateView: 加載換膚View成功..." + view);
return view;
}
/**
* 創建系統自帶View
*/
private View createView(String name, String prefix, Context context, AttributeSet attrs) {
View view = null;
try {
view = LayoutInflater.from(context).createView(name, prefix, attrs);
} catch (ClassNotFoundException e) {
//
}
return view;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return null;
}
}
- 設置Factory
/**
* 設置佈局解析Factory
* 需要將LayoutInflater的mFactorySet變量設置爲false
*/
private void setFactory(Activity activity) {
try {
LayoutInflater inflater = activity.getLayoutInflater();
Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
field.setAccessible(true);
field.setBoolean(inflater, false);
//設置自己的Factory
LayoutInflaterCompat.setFactory2(inflater, new SkinFactory());
} catch (Exception e) {
e.printStackTrace();
}
}
在SkinFactory#onCreateView()中就可以獲取到我們標記的View了,這裏需要保存換膚的View,需要替換的屬性和屬性的值
三、創建一個Library項目製作皮膚包資源
- app默認的顏色資源
- 對應的皮膚包如下:
- 作爲皮膚包只需要
res
目錄可以將java的目錄代碼全部刪除 - 皮膚包中定義的
資源名稱必須與主app定義的一模一樣
- 然後通過
AS的菜單——>Build——>Build Bundle(s) / APK(s)——> Build APK(s)
就可以打包出來了
四、有了皮膚包資源就可以創建Resources
對象拿到res/
下的所有資源
- 創建Resources對象
/**
* 創建皮膚包的Resources
*
* @param path 皮膚包路徑
*/
public void createResources(Context context, String path) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, path);
Resources resources = context.getResources();
//創建對象
Resources skinResources = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());
//獲取皮膚包(也就是apk)的包名
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
String skinPackageName = packageInfo.packageName;
} catch (Exception e) {
e.printStackTrace();
}
}
- path 就是皮膚包路徑了
/sdcard/Android/data/com.azhon.dynamicskin/cache/dark.skin
- 通過
PackageManager
獲取皮膚包的包名,包名在獲取皮膚包內的資源時會用到
五、加載皮膚包內的資源,下面通過一個示例來講解
- 我們需要替換這個TextView的
background
,textColor
這兩個屬性
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/bg_1"
android:text="我是一個TextView"
android:textColor="@color/title_1"
android:textSize="16sp"
skin:enable="true" />
在自定義的SkinFactory
中就可以獲取每一個屬性和屬性對應的值,如下:
這裏的@開頭值後面的數字就是res下的資源對應的Id(也是就是R文件的Id)
先介紹一個重要的(api)方法
int resId = resources.getIdentifier(String name, String defType, String defPackage);
第一個參數:資源的名字,例如:bg_1、titile_1
第二個參數:資源類型,例如:drawable、color、string
第三個參數:resources資源對應的包名
根據資源id加載皮膚包內對應的資源
- 封裝的方法
/**
* 根據資源Id獲取資源的名稱
* @param resources app自身的資源對象
* @param skinResources 皮膚包創建的資源對象
* @param id 當前使用的資源id
*/
public static int getResourcesIdByName(Resources resources,Resources skinResources, String packageName, int id) {
String[] res = getResourcesById(resources, id);
//使用皮膚包創建的Resources加載資源
return skinResources.getIdentifier(res[0], res[1], packageName);
}
/**
* 根據資源Id獲取資源的名稱
*
* @param id 資源id
* @return 資源名稱
*/
public static String[] getResourcesById(Resources resources, int id) {
String entryName = resources.getResourceEntryName(id);
String typeName = resources.getResourceTypeName(id);
return new String[]{entryName, typeName};
}
- 獲取對應皮膚包內的資源id(2130968664就是獲取到的資源id)
int skinResId = getResourcesIdByName(context.getResources(),skinResources,skinPackageName,2130968664);
- 獲取到了資源的id,但是這個值是不能直接使用的需要在進一步操作
- 上面通過
getResourcesById()
這個方法知道了這個資源id是屬於color
類型的了,所以只要在調用一次getColor
即可
int color = skinResources.getColor(skinResId);
通過上面幾步就成功的拿到了皮膚包內對應的資源,最後就只要調用TextView的setTextColor(color)就可以成功的替換文字的顏色了,同理替換background也是一樣的。
- Resources也還提供了許多其它的方法:
Demo示例下載地址
需要將項目根目錄的 dark.skin 文件拷貝至/sdcard/Android/data/com.azhon.dynamicskin/cache/
目錄下
六、總結
- 干預View的加載創建,
Factory
的原理和使用 - 對一個apk包創建對應的
Resources對象
,AssetManager
、PackageManager
的使用 - 加載apk包內的資源,
Resources
的使用