換膚技術我一直都感覺很炫酷,畢竟這是一個看臉的時代,并且在某些程度上也能給使用者帶來更好的體驗進而提升産品競争力,閑話少絮,我們開始切入正題。
我們來思考下換膚換膚,到底換的是什麼?
其實很簡單無非就是顔色,圖檔,最多在加一個字型。
那麼怎麼替換呢?或者是怎樣的思路呢?
我們會發現他們都是資源,也就是說這些我們是可以通過AssetManager拿到的,是以我們把一個module做成皮膚包(主要就是res目錄下的檔案),把它下載下傳到本地然後用AssetManager去加載這個皮膚包的資源并替換就實作了我們的外置皮膚包換膚的需求,而内置換膚就更簡單了因為所有資源都是放到項目中的,隻要加載指定檔案夾下的資源就好了,這種方式比較核心的就是無論是顔色還是圖檔,他們的資源名和對應的皮膚包(外置換膚)或檔案夾中的資源名是一緻的(通過資源名來比對)。
可能我總結的歸納的不好,大家一下看不太懂,下面我們就細緻的實作下,首先來看内置換膚。
先上張整個項目的結構圖
1.主App

2.app依賴的庫,将換膚相關的關鍵操作抽取出來,符合架構思想
3.用于打包皮膚包APK(App類型的module),和前兩者沒有依賴關系是獨立的
對于内置換膚我們隻關心主app及skin_Library中的SkinActivity就可以了,因為換膚會涉及到極多Activity,是以我們把換膚操作都抽到SkinActivity這個父類中,讓需要換膚的Activity繼承他就好了。
1.内置換膚
public class SkinLocalActivity extends SkinActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_skin_local);
//這是系統的Api,設定日間模式:MODE_NIGHT 夜間模式:MODE_NIGHT_NO
//看到這裡一定要回頭去看下第一張圖檔,在res檔案夾下,不太細心的小夥伴可能沒有注意到
//比平時的項目多了一個values-night的檔案夾,當設定為夜間模式時,就會去加載這個檔案夾
//下的資源,同理如果涉及到了圖檔,可以自己建立一個drawable-night道理是一樣的
//但要注意的是切換了日間夜間模式後需要view重新整理,重新去加載一下資源下面會講到
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
}
}
// 點選事件
public void dayOrNight(View view) {
//得到目前皮膚模式
int uiMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
switch (uiMode) {
case Configuration.UI_MODE_NIGHT_NO:
//父類方法
setDayNightMode(AppCompatDelegate.MODE_NIGHT_YES);
//将切換後的皮膚類型緩存,下次打開App時讀取
PreferencesUtils.putBoolean(this, "isNight", true);
break;
case Configuration.UI_MODE_NIGHT_YES:
//父類方法
setDayNightMode(AppCompatDelegate.MODE_NIGHT_NO);
PreferencesUtils.putBoolean(this, "isNight", false);
break;
default:
break;
}
}
//父類重寫方法,用于設定此Activty是否需要換膚
@Override
protected boolean openChangeSkin() {
return true;
}
}
布局檔案
<?xml version="1.0" encoding="utf-8"?>
<com.example.skin.library.views.SkinnableLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/commonTextColor"
android:gravity="center"
android:orientation="vertical"
tools:context=".MainActivity">
<com.example.skin.library.views.SkinnableTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="30dp"
android:text="同學們,大家好!"
android:textColor="@color/commonTextColor1"
android:textSize="30sp"
android:textStyle="bold" />
<com.example.skin.library.views.SkinnableButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/commonTextColor"
android:onClick="dayOrNight"
android:padding="10dp"
android:text="日間 / 夜間"
android:textColor="@color/commonTextColor1"
android:textSize="25sp" />
</com.netease.skin.library.views.SkinnableLinearLayout>
緊接着我們看下父類是怎麼實作的
public class SkinActivity extends AppCompatActivity {
private CustomAppCompatViewInflater viewInflater;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
/**
* @return 是否開啟換膚,增加此開關是為了避免開發者誤繼承此父類,導緻未知bug
*/
protected boolean openChangeSkin() {
return false;
}
/**
* 根據類型進行換膚
*/
protected void setDayNightMode(@AppCompatDelegate.NightMode int nightMode) {
if (!openChangeSkin()) return;
//相容5.0及以上版本
final boolean isPost21 = Build.VERSION.SDK_INT >= 21;
//設定日間/夜間模式
getDelegate().setLocalNightMode(nightMode);
// 設定以下的顔色,不是太重點,稍後再看
if (isPost21) {
// 換狀态欄
StatusBarUtils.forStatusBar(this);
// 換标題欄
ActionBarUtils.forActionBar(this);
// 換底部導航欄
NavigationUtils.forNavigation(this);
}
// 重點來了!!!!!!
// 布局裡那麼多的view都要換膚,就是從頂層Decorview從上往下周遊設定的
View decorView = getWindow().getDecorView();
applyDayNightForView(decorView);
}
/**
* 回調接口 給具體控件換膚操作
*/
protected void applyDayNightForView(View view) {
// 這裡就是去填SkinLocalActivity 留下的坑,如何去重新整理view,去重新加載資源
// 在此我們是通過自定義view實作的,這個ViewsMatch是一個接口,自定義view都去實作這個接口 ,看到下面代碼大家就了解了
if (view instanceof ViewsMatch) {
ViewsMatch viewsMatch = (ViewsMatch) view;
viewsMatch.skinnableView();
}
// 當view是ViewGroup時,就去遞歸操作
if (view instanceof ViewGroup) {
ViewGroup parent = (ViewGroup) view;
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
applyDayNightForView(parent.getChildAt(i));
}
}
}
}
看下超級無敵簡單的ViewsMatch接口
public interface ViewsMatch {
/**
* 控件換膚
*/
void skinnableView();
}
在給大家看一個自定義view的例子
public class SkinnableButton extends AppCompatButton implements ViewsMatch {
private AttrsBean attrsBean;
public SkinnableButton(Context context) {
this(context, null);
}
public SkinnableButton(Context context, AttributeSet attrs) {
this(context, attrs, android.support.v7.appcompat.R.attr.buttonStyle);
}
public SkinnableButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 這是個優化操作,把view的屬性存到集合,下次用到直接從集合中取就可以了
attrsBean = new AttrsBean();
// 根據自定義屬性,比對控件屬性的類型集合,如:background + textColor
TypedArray typedArray = context.obtainStyledAttributes(attrs,
R.styleable.SkinnableButton,
defStyleAttr, 0);
// 存儲到臨時JavaBean對象
attrsBean.saveViewResource(typedArray, R.styleable.SkinnableButton);
// 這一句回收非常重要!obtainStyledAttributes()有文法提示!!
typedArray.recycle();
}
@Override
public void skinnableView() {
// 根據自定義屬性,擷取styleable中的background屬性
int key = R.styleable.SkinnableButton[R.styleable.SkinnableButton_android_background];
// 根據styleable擷取控件某屬性的resourceId
int backgroundResourceId = attrsBean.getViewResource(key);
if (backgroundResourceId > 0) {
// 相容包轉換
Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
// 控件自帶api,這裡不用setBackgroundColor()因為在9.0測試不通過
// setBackgroundDrawable本來過時了,但是相容包重寫了方法
setBackgroundDrawable(drawable);
}
// 根據自定義屬性,擷取styleable中的textColor屬性
key = R.styleable.SkinnableButton[R.styleable.SkinnableButton_android_textColor];
int textColorResourceId = attrsBean.getViewResource(key);
if (textColorResourceId > 0) {
ColorStateList color = ContextCompat.getColorStateList(getContext(), textColorResourceId);
setTextColor(color);
}
}
}
如此,大家可以去拓展所有你要用到的view,Textview,ImageView,LinearLayout,RelativeLayout等等,然後在布局中用自定義view就可以了,其實還有一種更符合架構的設計,就是當解析xml布局檔案時,比如拿到Button節點我們給建立一個自定義的SkinnableButton而不是Button,這樣的話其他的開發人員就不必關心去找他所需的自定義控件在哪裡了,但我們在實作過程需要大家去閱讀研究View加載的源碼,現在我們來梳理一下。
1.說起解析xml布局毫無疑問要去看下setContentView(@LayoutRes int layoutResID)這個方法(不清楚布局檔案加載流程點選這裡),然後一步步跟進最終我們來到LayoutInflater的createViewFromTag方法。
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
.......
//我們隻看重點,到最後xml裡的view都是通過Factory2(或Factory)的onCreateView()方法建立的,那麼Factory2(或Factory)又是在哪裡設定的呢?
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;
} catch (ClassNotFoundException e) {
.....
} catch (Exception e) {
.....
}
}
關于這個問題,其實我們是有迹可循的,
其一:在setContentView中Factory2對象已被使用必然要在setContentView方法調用之前找答案
其二:了解過Activity源碼的小夥伴應該知道Activity就是Factory2接口的實作類
是以我們要到onCreate()方法中一探究竟了。
當來到AppCompatActivity的onCreate()方法時,我們找到了相應的操作
protected void onCreate(@Nullable Bundle savedInstanceState) {
AppCompatDelegate delegate = this.getDelegate();
//就是這裡
delegate.installViewFactory();
delegate.onCreate(savedInstanceState);
......
super.onCreate(savedInstanceState);
}
繼續跟進,AppCompatDelegate是一個抽象類,點選左邊的圖示進到他的子類:AppCompatDelegateImpl
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(this.mContext);
//答案在此揭曉,當layoutInflater.getFactory() == null時進行了設定,
//有的小夥伴要有疑問了,這個layoutInflater 不是在這裡新建立的嗎,給這個layoutInflater 設定了Factory2,
//PhoneWindow中setContentView中的mLayoutInflater怎麼會也設定了Factory2?
if (layoutInflater.getFactory() == null) {
//傳的this是因為這個類也實作了Factory2接口,并實作了Factory2的方法,onCreateView()
//setFactory2此方法中,factory = factory2,factory被指派
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
//這裡是核心,我們要做的就是在子類(SkinActivity)中設定Factory,然後走到這個分支,下面會提到
Log.i("AppCompatDelegate", "The Activity's LayoutInflater already has a Factory installed so we can not install AppCompat's");
}
}
對于這個問題大家可以看下LayoutInflater的源碼或LayoutInflater源碼分析這篇文章,這裡直接給大家結論:
除了第一次LayoutInflater.from(getBaseContext())建立了一個PhoneLayoutInflater執行個體,再調用
LayoutInflater layoutInflater = LayoutInflater.from(this);
通過現有的LayoutInflater建立一個新的LayoutInflater副本(LayoutInflater的cloneInContext()方法),唯一變化的地方是指向不同的上下文對象。
這次終于到了在上文梳理setContentView加載布局檔案最後的createViewFromTag()方法中
mFactory2.onCreateView(parent, name, context, attrs);的實作位置了。
繼續看AppCompatDelegateImpl類的Factory2接口的實作方法
public View createView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs) {
.....
//直接來到核心代碼,繼續進入下面的createView
return this.mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext, IS_PRE_LOLLIPOP, true, VectorEnabledTintResources.shouldBeUsed());
}
到此我們得到了最終的答案,是根據xml解析出的節點的name,來建立對應得view的,那我們重寫這一塊讓他建立我們的自定義view不就可以了?
final View createView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs, boolean inheritContext, boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
View view = null;
byte var12 = -1;
switch(name.hashCode()) {
case -1946472170:
if (name.equals("RatingBar")) {
var12 = 11;
}
break;
case -1455429095:
if (name.equals("CheckedTextView")) {
var12 = 8;
}
break;
case -1346021293:
if (name.equals("MultiAutoCompleteTextView")) {
var12 = 10;
}
break;
case -938935918:
if (name.equals("TextView")) {
var12 = 0;
}
break;
case -937446323:
if (name.equals("ImageButton")) {
var12 = 5;
}
break;
case -658531749:
if (name.equals("SeekBar")) {
var12 = 12;
}
break;
case -339785223:
if (name.equals("Spinner")) {
var12 = 4;
}
break;
case 776382189:
if (name.equals("RadioButton")) {
var12 = 7;
}
break;
case 1125864064:
if (name.equals("ImageView")) {
var12 = 1;
}
break;
case 1413872058:
if (name.equals("AutoCompleteTextView")) {
var12 = 9;
}
break;
case 1601505219:
if (name.equals("CheckBox")) {
var12 = 6;
}
break;
case 1666676343:
if (name.equals("EditText")) {
var12 = 3;
}
break;
case 2001146706:
if (name.equals("Button")) {
var12 = 2;
}
}
switch(var12) {
case 0:
view = this.createTextView(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 1:
view = this.createImageView(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 2:
view = this.createButton(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 3:
view = this.createEditText(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 4:
view = this.createSpinner(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 5:
view = this.createImageButton(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 6:
view = this.createCheckBox(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 7:
view = this.createRadioButton(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 8:
view = this.createCheckedTextView(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 9:
view = this.createAutoCompleteTextView(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 10:
view = this.createMultiAutoCompleteTextView(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 11:
view = this.createRatingBar(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 12:
view = this.createSeekBar(context, attrs);
this.verifyNotNull((View)view, name);
break;
default:
view = this.createView(context, name, attrs);
}
if (view == null && originalContext != context) {
view = this.createViewFromTag(context, name, attrs);
}
if (view != null) {
this.checkOnClickListener((View)view, attrs);
}
return (View)view;
}
OK,我們建立一個類繼承AppCompatViewInflater
public final class CustomAppCompatViewInflater extends AppCompatViewInflater {
private Context context;
//控件名
private String name;
//上下文
private AttributeSet attrs;
public CustomAppCompatViewInflater(@NonNull Context context) {
this.context = context;
}
public void setName(String name) {
this.name = name;
}
public void setAttrs(AttributeSet attrs) {
this.attrs = attrs;
}
public View autoMatch() {
View view = null;
switch (name) {
case "LinearLayout":
// view = super.createTextView(context, attrs); // 源碼寫法
view = new SkinnableLinearLayout(context, attrs);
this.verifyNotNull(view, name);
break;
case "RelativeLayout":
view = new SkinnableRelativeLayout(context, attrs);
this.verifyNotNull(view, name);
break;
case "TextView":
view = new SkinnableTextView(context, attrs);
this.verifyNotNull(view, name);
break;
case "ImageView":
view = new SkinnableImageView(context, attrs);
this.verifyNotNull(view, name);
break;
case "Button":
view = new SkinnableButton(context, attrs);
this.verifyNotNull(view, name);
break;
}
return view;
}
/**
* 校驗控件不為空(源碼方法,由于private修飾,隻能複制過來了。為了代碼健壯,可有可無)
*
* @param view 被校驗控件,如:AppCompatTextView extends TextView(v7相容包,相容是重點!!!)
* @param name 控件名,如:"ImageView"
*/
private void verifyNotNull(View view, String name) {
if (view == null) {
throw new IllegalStateException(this.getClass().getName() + " asked to inflate view for <" + name + ">, but returned null");
}
}
}
然後在SkinActivity重寫onCreateView方法,用我們自己布局加載器來建立view
private CustomAppCompatViewInflater viewInflater;
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
if (openChangeSkin()) {
if (viewInflater == null) {
viewInflater = new CustomAppCompatViewInflater(context);
}
viewInflater.setName(name);
viewInflater.setAttrs(attrs);
return viewInflater.autoMatch();
}
return super.onCreateView(name, context, attrs);
}
還有重要一步要做的就是要去攔截系統的布局加載方式
@Override
protected void onCreate(Bundle savedInstanceState) {
//攔截原生加載xml的方式,自己實作
//原生加載xml是通過解析xml檔案,根據如Textview則new一個AppCompatTextView,
//我們要做的就是不new一個AppCompatTextView,而是new一個SkinTextview(自定義view),
//這個在xml中寫一個而是new一個SkinTextview是一緻的
//以此方法偷梁換柱來實作換膚
//在上面的installViewFactory()有提到
LayoutInflater layoutInflater = LayoutInflater.from(this);
LayoutInflaterCompat.setFactory2(layoutInflater, this);
super.onCreate(savedInstanceState);
}
到此将布局檔案中的自定義view改回系統提供的,運作完會發現效果是一樣的。
在内置換膚的最後還有一點要注意,在AndroidManifest中的Activity标簽下添加一個屬性:
<activity
android:name=".SkinLocalActivity"
//在不同品牌的手機上,不加這一行可能會導緻狀态欄、标題欄、底部導航欄換膚失敗
android:configChanges="uiMode" />
<activity android:name=".SkinOutActivity" />
2.外置換膚
其實外置換膚也是換湯不換藥隻不過是去加載皮膚資源包下的資源,如第三張圖檔所示,建立一個Module,然後把主app要換的資源都對應添加到這個module中,注意資源的名字一定是要一樣的一一對應的。
我們來看下這個換膚的管理類
SkinManager
/**
* 皮膚管理器
* 加載應用資源(app内置:res/xxx) or 存儲資源(下載下傳皮膚包:net163.skin)
*/
public class SkinManager {
private static SkinManager instance;
private Application application;
private Map<String, SkinCache> cacheSkin;
private Resources appResources; // 用于加載app内置資源
private Resources skinResources; // 用于加載皮膚包資源
private String skinPackageName; // 皮膚包資源所在包名(注:皮膚包不在app内,也不限包名)
private boolean isDefaultSkin = true; // 應用預設皮膚(app内置)
private static final String ADD_ASSET_PATH = "addAssetPath"; // 方法名
private SkinManager(Application application) {
this.application = application;
appResources = application.getResources();
cacheSkin = new HashMap<>();
}
/**
* 單例方法,目的是初始化app内置資源(越早越好,使用者的操作可能是:換膚後的第2次冷啟動)
*/
public static void init(Application application) {
if (instance == null) {
synchronized (SkinManager.class) {
if (instance == null) {
instance = new SkinManager(application);
}
}
}
}
public static SkinManager getInstance() {
return instance;
}
public boolean isDefaultSkin() {
return isDefaultSkin;
}
//傳回值情況特殊,可能是color/drawable/mipmap
public Object getBackgroundOrSrc(int resourceId) {
// 需要擷取目前屬性的類型名Resources.getResourceTypeName(resourceId)再判斷
String resourceTypeName = appResources.getResourceTypeName(resourceId);
switch (resourceTypeName){
case "color":
return getColor(resourceId);
case "mipmap":
case "drawable":
return getDrawableOrMipMap(resourceId);
}
return null;
}
public int getColor(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return isDefaultSkin ? appResources.getColor(ids) : skinResources.getColor(ids);
}
// mipmap和drawable統一用法(待測)
public Drawable getDrawableOrMipMap(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return isDefaultSkin ? appResources.getDrawable(ids) : skinResources.getDrawable(ids);
}
public ColorStateList getColorStateList(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return isDefaultSkin ? appResources.getColorStateList(ids) : skinResources.getColorStateList(ids);
}
public String getString(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return isDefaultSkin ? appResources.getString(ids) : skinResources.getString(ids);
}
// 獲得字型
public Typeface getTypeface(int resourceId) {
// 通過資源ID擷取資源path,參考:resources.arsc資源映射表
String skinTypefacePath = getString(resourceId);
// 路徑為空,使用系統預設字型
if (TextUtils.isEmpty(skinTypefacePath)) return Typeface.DEFAULT;
return isDefaultSkin ? Typeface.createFromAsset(appResources.getAssets(), skinTypefacePath)
: Typeface.createFromAsset(skinResources.getAssets(), skinTypefacePath);
}
/**
* 加載皮膚包資源
*
* @param skinPath 皮膚包路徑,為空則加載app内置資源
*/
public void loaderSkinResources(String skinPath) {
//優化:如果沒有皮膚包或換膚動作直接傳回
if (TextUtils.isEmpty(skinPath)){
isDefaultSkin = true;
return;
}
//優化:app冷啟動,熱啟動可以取緩存對象
if (cacheSkin.containsKey(skinPath)){
isDefaultSkin = false;
SkinCache skinCache = cacheSkin.get(skinPath);
if (null!=skinCache){
skinResources = skinCache.getSkinResources();
skinPackageName = skinCache.getSkinPackageName();
return;
}
}
try {
//建立資料總管(此處不能用:application.getAssets())
AssetManager assetManager = AssetManager.class.newInstance();
//由于AssetManager中的addAssetPath和setApkAssets方法都被@hide,目前隻能通過反射去執行方法
Method addAssetPath = assetManager.getClass().getDeclaredMethod(ADD_ASSET_PATH,String.class);
//設定私有方法可通路
addAssetPath.setAccessible(true);
// 執行addAssetPath方法
addAssetPath.invoke(assetManager, skinPath);
//==============================================================================
// 如果還是擔心@hide限制,可以反射addAssetPathInternal()方法,參考源碼366行 + 387行
//==============================================================================
// 建立加載外部的皮膚包(net163.skin)檔案Resources(注:依然是本應用加載)
skinResources = new Resources(assetManager,appResources.getDisplayMetrics(),appResources.getConfiguration());
//根據apk檔案路徑(皮膚包也是apk檔案),擷取應用的包名
skinPackageName = application.getPackageManager().getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES).packageName;
// 無法擷取皮膚包應用的包名,則加載app内置資源
isDefaultSkin = TextUtils.isEmpty(skinPackageName);
if (!isDefaultSkin) {
cacheSkin.put(skinPath, new SkinCache(skinResources, skinPackageName));
}
Log.e("skinPackageName >>> ", skinPackageName);
} catch (Exception e){
e.printStackTrace();
// 發生異常,預判:通過skinPath擷取skinPacakageName失敗!
isDefaultSkin = true;
}
}
/**
* 參考:resources.arsc資源映射表
* 通過ID值擷取資源 Name 和 Type
*
* @param resourceId 資源ID值
* @return 如果沒有皮膚包則加載app内置資源ID,反之加載皮膚包指定資源ID
*/
private int getSkinResourceIds(int resourceId) {
// 優化:如果沒有皮膚包或者沒做換膚動作,直接傳回app内置資源!
if (isDefaultSkin) return resourceId;
// 使用app内置資源加載,是因為内置資源與皮膚包資源一一對應(“netease_bg”, “drawable”)
String resourceName = appResources.getResourceEntryName(resourceId);
String resourceType = appResources.getResourceTypeName(resourceId);
// 動态擷取皮膚包内的指定資源ID
// getResources().getIdentifier(“netease_bg”, “drawable”, “com.netease.skin.packages”);
int skinResourceId = skinResources.getIdentifier(resourceName, resourceType, skinPackageName);
// 源碼1924行:(0 is not a valid resource ID.)
return skinResourceId == 0 ? resourceId : skinResourceId;
}
}
其核心思想就是,拿到自定義view中的背景、顔色等屬性的id,進而得到這些屬性的類型(drawable、colors、mipmap等)和資源的名字(abc.png <color name="skin_textColor">#FFFFFF</color>),根據二者去拿到皮膚包裡對應得資源id,然後将這個資源id指派給自定義view的背景和顔色。
在Application的onCreate中初始化SkinManager
SkinManager.init(this);
在Activity中使用
/**
* 如果圖示有固定的尺寸,不需要更改,那麼drawable更加适合
* 如果需要變大變小變大變小的,有動畫的,放在mipmap中能有更高的品質
*/
public class SkinOutActivity extends SkinActivity {
private String skinPath;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// File.separator含義:拼接 /
// 資源包路徑,(按自己需求)
skinPath = Environment.getExternalStorageDirectory().getAbsolutePath()
+ File.separator + "net163.skin";
// 運作時權限申請(6.0+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
String[] perms = {Manifest.permission.WRITE_EXTERNAL_STORAGE};
if (checkSelfPermission(perms[0]) == PackageManager.PERMISSION_DENIED) {
requestPermissions(perms, 200);
}
}
if (("net163").equals(PreferencesUtils.getString(this, "currentSkin"))) {
skinDynamic(skinPath, R.color.skin_item_color);
} else {
defaultSkin(R.color.colorPrimary);
}
}
// 換膚按鈕(api限制:5.0版本)
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public void skinDynamic(View view) {
// 真實項目中:需要先判斷目前皮膚,避免重複操作!
if (!("net163").equals(PreferencesUtils.getString(this, "currentSkin"))) {
Log.e("netease >>> ", "-------------start-------------");
long start = System.currentTimeMillis();
skinDynamic(skinPath, R.color.skin_item_color);
PreferencesUtils.putString(this, "currentSkin", "net163");
long end = System.currentTimeMillis() - start;
Log.e("netease >>> ", "換膚耗時(毫秒):" + end);
Log.e("netease >>> ", "-------------end---------------");
}
}
// 預設按鈕(api限制:5.0版本)
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public void skinDefault(View view) {
if (!("default").equals(PreferencesUtils.getString(this, "currentSkin"))) {
Log.e("netease >>> ", "-------------start-------------");
long start = System.currentTimeMillis();
defaultSkin(R.color.colorPrimary);
PreferencesUtils.putString(this, "currentSkin", "default");
long end = System.currentTimeMillis() - start;
Log.e("netease >>> ", "還原耗時(毫秒):" + end);
Log.e("netease >>> ", "-------------end---------------");
}
}
//開啟換膚
@Override
protected boolean openChangeSkin() {
return true;
}
public void jumpSelf(View view) {
startActivity(new Intent(this,ThreeActivity.class));
}
}
将皮膚包module打包成Apk,重命名net163.skin(自己随意),并将其放到手機sdcard中(位置随意,與代碼中取檔案的位置對應上就可以),到此兩種換膚全部完成。本篇文章主要是核心内容,完整項目可由以下連結擷取。
換膚demo