背景
現(xiàn)項(xiàng)目中涉及紅色掘托、金色主題卦睹,同時(shí)需要適配紅色暗黑畦戒、金色暗黑,本地需要手動(dòng)維護(hù)4套色值结序,并且切換主題時(shí)需要重新銷毀創(chuàng)建頁面障斋,維護(hù)跟用戶體驗(yàn)都不是很友好。
設(shè)計(jì)思路來源
通過調(diào)研,發(fā)現(xiàn)換膚的實(shí)現(xiàn)原理比較符合適用當(dāng)前項(xiàng)目的使用場(chǎng)景,開源項(xiàng)目 Android-Skin-Loader
通過查看源碼換膚實(shí)現(xiàn)原理其實(shí)為 通過下載或者加載本地資源包徐鹤,這里的資源包其實(shí)就是一個(gè)只有資源文件的項(xiàng)目通過編譯打包生成的.apk文件垃环,點(diǎn)擊切換時(shí),通過提前手動(dòng)綁定view和要改變的資源類型 將資源Resource替換成資源包的Resource資源進(jìn)行設(shè)置替換返敬,從而達(dá)到換膚的效果遂庄。
由此整理出方案需要自行實(shí)現(xiàn)的點(diǎn)
- 獲取需要支持主題切換的view和要改變的屬性類型
- 資源包不通過.apk的形式存在而是跟正常的資源文件存放的于項(xiàng)目中,可以在XML布局里直接使用
- 定義需要支持適配的具體屬性 android:textColor|android:background|android:src
- 自定義屬性配置 是否需要支持切換 以及 是否只區(qū)分暗黑不區(qū)分紅色主題劲赠、金色主題
- 處理debug開發(fā)的日志以及異常處理情況
- 支持除textColor 和 background涛目、src,能支持屬性擴(kuò)展
- 支持動(dòng)態(tài)創(chuàng)建view動(dòng)態(tài)切換主題
具體實(shí)現(xiàn)
前提了解下LayoutInflater原理
LayoutInflatersetFactory(LayoutInflater.Factoryfactory)和setFactory2(LayoutInflater.Factory2 factory)兩個(gè)方法可以讓你去自定義布局的填充(有點(diǎn)類似于過濾器凛澎,我們?cè)谔畛溥@個(gè)View之前可以手動(dòng)綁定view和要改變的資源類型)霹肝,F(xiàn)actory2 是在API 11才添加的。
通過閱讀源碼可以發(fā)現(xiàn),我們?cè)谶M(jìn)入setContentView(R.layout.activity_main)可以看到
@Override
public void setContentView(@LayoutRes int layoutResID) {
getDelegate().setContentView(layoutResID);
}
這里的getDelegate()獲取到的是AppCompatDelegateImpl,我們可以看到
@RestrictTo(LIBRARY)
class AppCompatDelegateImpl extends AppCompatDelegate
implements MenuBuilder.Callback, LayoutInflater.Factory2 {
實(shí)現(xiàn)了LayoutInflater.Factory2接口,在看 獲取到的是AppCompatDelegateImpl的
@Override
public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);
mAppCompatWindowCallback.getWrapped().onContentChanged();
}
LayoutInflater.from(mContext)獲取最終獲取的是ontext.getSystemService(Context.LAYOUT_INFLATER_SERVICE)
/**
* 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;
}
繼續(xù)往里看會(huì)找到
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");
final Context inflaterContext = mContext;
final AttributeSet attrs = Xml.asAttributeSet(parser);
Context lastContext = (Context) mConstructorArgs[0];
mConstructorArgs[0] = inflaterContext;
View result = root;
try {
advanceToRootNode(parser);
final String name = parser.getName();
if (DEBUG) {
System.out.println("**************************");
System.out.println("Creating root view: "
+ name);
System.out.println("**************************");
}
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;
if (root != null) {
if (DEBUG) {
System.out.println("Creating params from root: " +
root);
}
// Create layout params that match root, if supplied
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
// Set the layout params for temp if we are not
// attaching. (If we are, we use addView, below)
temp.setLayoutParams(params);
}
}
if (DEBUG) {
System.out.println("-----> start inflating children");
}
// Inflate all children under temp against its context.
rInflateChildren(parser, temp, attrs, true);
if (DEBUG) {
System.out.println("-----> done inflating children");
}
// We are supposed to attach all the views we found (int temp)
// to root. Do that now.
if (root != null && attachToRoot) {
root.addView(temp, params);
}
// Decide whether to return the root that was passed in or the
// top view found in xml.
if (root == null || !attachToRoot) {
result = temp;
}
}
} catch (XmlPullParserException e) {
final InflateException ie = new InflateException(e.getMessage(), e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (Exception e) {
final InflateException ie = new InflateException(
getParserStateDescription(inflaterContext, attrs)
+ ": " + e.getMessage(), e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} finally {
// Don't retain static reference on context.
mConstructorArgs[0] = lastContext;
mConstructorArgs[1] = null;
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
return result;
}
}
可以看到如果不是merge標(biāo)簽會(huì)通過createViewFromTag(root,name,inflaterContext, attrs)創(chuàng)建view塑煎,找到方法
@UnsupportedAppUsage
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
if (name.equals("view")) {
name = attrs.getAttributeValue(null, "class");
}
// Apply a theme wrapper, if allowed and one is specified.
if (!ignoreThemeAttr) {
final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
final int themeResId = ta.getResourceId(0, 0);
if (themeResId != 0) {
context = new ContextThemeWrapper(context, themeResId);
}
ta.recycle();
}
try {
View view = tryCreateView(parent, name, context, attrs);
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(context, parent, name, attrs);
} else {
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (InflateException e) {
throw e;
} catch (ClassNotFoundException e) {
final InflateException ie = new InflateException(
getParserStateDescription(context, attrs)
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (Exception e) {
final InflateException ie = new InflateException(
getParserStateDescription(context, attrs)
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
}
}
最終我們?cè)趖ryCreateView(@Nullable View parent, @NonNull String name,@NonNull Context context,@NonNull AttributeSet attrs)里看到
@UnsupportedAppUsage(trackingBug = 122360734)
@Nullable
public final View tryCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context,
@NonNull AttributeSet attrs) {
if (name.equals(TAG_1995)) {
// Let's party like it's 1995!
return new BlinkLayout(context, attrs);
}
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);
}
return view;
}
最終是調(diào)用的LayoutInflater的setFactory2()方法創(chuàng)建View沫换,當(dāng)我們不手動(dòng)調(diào)用設(shè)置Factory2,我們還記得前面說的AppCompatDelegateImpl實(shí)現(xiàn)了LayoutInflater.Factory2接口重寫了createView()方法并設(shè)置了
@Override
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(mContext);
if (layoutInflater.getFactory() == null) {
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else {
if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
+ " so we can not install AppCompat's");
}
}
}
@Override
public View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs) {
if (mAppCompatViewInflater == null) {
TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme);
String viewInflaterClassName =
a.getString(R.styleable.AppCompatTheme_viewInflaterClass);
if ((viewInflaterClassName == null)
|| AppCompatViewInflater.class.getName().equals(viewInflaterClassName)) {
// Either default class name or set explicitly to null. In both cases
// create the base inflater (no reflection)
mAppCompatViewInflater = new AppCompatViewInflater();
} else {
try {
Class<?> viewInflaterClass = Class.forName(viewInflaterClassName);
mAppCompatViewInflater =
(AppCompatViewInflater) viewInflaterClass.getDeclaredConstructor()
.newInstance();
} catch (Throwable t) {
Log.i(TAG, "Failed to instantiate custom view inflater "
+ viewInflaterClassName + ". Falling back to default.", t);
mAppCompatViewInflater = new AppCompatViewInflater();
}
}
}
發(fā)現(xiàn)具體是通過AppCompatViewInflater的createView()去創(chuàng)建的View具體
final View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs, boolean inheritContext,
boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
final Context originalContext = context;
// We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
// by using the parent's context
if (inheritContext && parent != null) {
context = parent.getContext();
}
if (readAndroidTheme || readAppTheme) {
// We then apply the theme on the context, if specified
context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
}
if (wrapContext) {
context = TintContextWrapper.wrap(context);
}
View view = null;
// We need to 'inject' our tint aware Views in place of the standard framework versions
switch (name) {
case "TextView":
view = createTextView(context, attrs);
verifyNotNull(view, name);
break;
case "ImageView":
view = createImageView(context, attrs);
verifyNotNull(view, name);
break;
case "Button":
view = createButton(context, attrs);
verifyNotNull(view, name);
break;
case "EditText":
view = createEditText(context, attrs);
verifyNotNull(view, name);
break;
case "Spinner":
view = createSpinner(context, attrs);
verifyNotNull(view, name);
break;
case "ImageButton":
view = createImageButton(context, attrs);
verifyNotNull(view, name);
break;
case "CheckBox":
view = createCheckBox(context, attrs);
verifyNotNull(view, name);
break;
case "RadioButton":
view = createRadioButton(context, attrs);
verifyNotNull(view, name);
break;
case "CheckedTextView":
view = createCheckedTextView(context, attrs);
verifyNotNull(view, name);
break;
case "AutoCompleteTextView":
view = createAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "MultiAutoCompleteTextView":
view = createMultiAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "RatingBar":
view = createRatingBar(context, attrs);
verifyNotNull(view, name);
break;
case "SeekBar":
view = createSeekBar(context, attrs);
verifyNotNull(view, name);
break;
case "ToggleButton":
view = createToggleButton(context, attrs);
verifyNotNull(view, name);
break;
default:
// The fallback that allows extending class to take over view inflation
// for other tags. Note that we don't check that the result is not-null.
// That allows the custom inflater path to fall back on the default one
// later in this method.
view = createView(context, name, attrs);
}
if (view == null && originalContext != context) {
// If the original context does not equal our themed context, then we need to manually
// inflate it using the name so that android:theme takes effect.
view = createViewFromTag(context, name, attrs);
}
if (view != null) {
// If we have created a view, check its android:onClick
checkOnClickListener(view, attrs);
}
return view;
}
通過上面的了解我們可以大概知道可以通過自定義一個(gè)ThemeInflaterFactory implements LayoutInflater.Factory2用來解析XML布局創(chuàng)建View(相當(dāng)于hook主系統(tǒng)創(chuàng)建view的過程)最铁。這里我們可以合理的保存下來需要適配主題的view以及屬性
ThemeInflaterFactory的功能 具體邏輯可以查看代碼
- 維護(hù)一個(gè)mThemeItemMap集合讯赏,用來保存需要適配的view 以及屬性垮兑,并對(duì)外提供添加跟清除方法
- ThemeItem對(duì)象包含一個(gè)View,以及需要修改的屬性擴(kuò)展BaseAttr漱挎,具體實(shí)現(xiàn)當(dāng)前有BackgroundAttr甥角、ImageViewSrcAttr、TextColorAttr
- 對(duì)外提供applyTheme方法识樱,遍歷集合mThemeItemMap,修改屬性值
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
boolean isThemeEnable = attrs.getAttributeBooleanValue(ThemeConfig.NAMESPACE, ThemeConfig.ATTR_THEME_ENABLE, false);
//調(diào)用系統(tǒng)創(chuàng)建基本控件
AppCompatDelegate delegate = mAppCompatActivity.getDelegate();
View view = delegate.createView(parent, name, context, attrs);
if (isThemeEnable || ThemeConfig.isGlobalSkinApply()) { //控件支持切換模式 或者開啟類全局支持開關(guān)
if (view == null) {
view = ViewCreate.createViewFromTag(context, name, attrs);
}
if (view == null) {
return null;
}
parseSkinAttr(context, attrs, view);
}
return view;
}
private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
List<BaseAttr> viewAttrs = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
if ("style".equals(attrName)) { //mxl布局引入style
int[] skinAttrs = new int[]{android.R.attr.textColor, android.R.attr.background};
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, skinAttrs, 0, 0);
int textColorId = a.getResourceId(0, -1);
int backgroundId = a.getResourceId(1, -1);
if (textColorId != -1) {
String entryName = context.getResources().getResourceEntryName(textColorId);
String typeName = context.getResources().getResourceTypeName(textColorId);
BaseAttr 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);
BaseAttr skinAttr = AttrFactory.get("background", backgroundId, entryName, typeName);
if (skinAttr != null) {
viewAttrs.add(skinAttr);
}
}
a.recycle();
continue;
}
if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
try {
int id = Integer.parseInt(attrValue.substring(1));
if (id == 0) {
continue;
}
boolean isOnlyDark = attrs.getAttributeBooleanValue(ThemeConfig.NAMESPACE, ThemeConfig.ATTR_THEME_ONLY_DARK, false);
String entryName = context.getResources().getResourceEntryName(id);
String typeName = context.getResources().getResourceTypeName(id);
BaseAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName, isOnlyDark);
if (mSkinAttr != null) {
viewAttrs.add(mSkinAttr);
}
} catch (NumberFormatException e) {
}
}
}
if (!viewAttrs.isEmpty()) {
ThemeItem skinItem = new ThemeItem();
skinItem.view = view;
skinItem.attrs = viewAttrs;
mThemeItemMap.put(skinItem.view, skinItem);
if (!AppThemeManager.getInstance().isNormalTheme() || AppThemeManager.getInstance().isInNightTheme()) {
skinItem.changeTheme();
}
}
}
view 支持主題切換或者全局支持開關(guān)打開,保存對(duì)應(yīng)的view以及屬性AttrFactory獲取的BaseAttr的實(shí)現(xiàn)類到ThemeItem里,保存于mThemeItemMap集合里
/**
* 運(yùn)用主題
*/
public void applyTheme() {
if (mThemeItemMap.isEmpty()) {
return;
}
for (View view : mThemeItemMap.keySet()) {
if (view == null) {
continue;
}
mThemeItemMap.get(view).changeTheme();
}
}
對(duì)外提供方法修改主題
AppThemeManager的功能
- 維護(hù)一個(gè)主題切換監(jiān)聽集合List<IThemeUpdate>mThemeObservers,在baseActivity實(shí)現(xiàn)IThemeUpdate接口,并調(diào)用AppThemeManager的addObserver(IThemeUpdate observer)添加監(jiān)聽震束,對(duì)應(yīng)onDestory移除監(jiān)聽,
- 點(diǎn)擊切換主題時(shí)調(diào)用notifyThemeUpdate()方法遍歷集合通知各個(gè)頁面調(diào)用ThemeInflaterFactory對(duì)外提供applyTheme方法修改對(duì)應(yīng)的屬性資源值
ThemeResourceUtil的功能
- 通過編譯后的資源在R文件的id怜庸,獲取到資源名稱,在根據(jù)當(dāng)前主題拼接資源名稱垢村,再獲取R文件對(duì)應(yīng)拼接后到資源id返回供BaseAttr的 applyTheme(view)設(shè)置切換后的資源達(dá)到切換主題的功能
- ThemeConfig 配置信息割疾,包括是否開啟全局view支持主題切換開關(guān)\debug模式開關(guān)\狀態(tài)欄適配開關(guān)\本地主題記錄sp等
具體使用
1.在Application初始化AppThemeManager以及配置ThemeConfig
public class DarkApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
AppThemeManager.getInstance().init(this);
ThemeConfig.setCanChangeStatusColor(true);
ThemeConfig.enableGlobalThemeApply();
ThemeConfig.setDebug(true);
}
}
2.BaseActivity實(shí)現(xiàn)IThemeUpdate, IDynamicNewView 接口
public class BaseActivity extends AppCompatActivity implements IThemeUpdate, IDynamicNewView {
/**
* 自定義 InflaterFactory
*/
private ThemeInflaterFactory mThemeInflaterFactory;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
mThemeInflaterFactory = new ThemeInflaterFactory(this);
LayoutInflaterCompat.setFactory2(getLayoutInflater(), mThemeInflaterFactory);
super.onCreate(savedInstanceState);
AppThemeManager.getInstance().addObserver(this);
changeStatusColor();
}
public void changeStatusColor() {
if (!ThemeConfig.isCanChangeStatusColor()) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
int color = ThemeResourceUtil.getColorPrimaryDark();
if (color != -1) {
Window window = getWindow();
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
window.setStatusBarColor(ThemeResourceUtil.getColorPrimaryDark());
}
}
}
public ThemeInflaterFactory getInflaterFactory() {
return mThemeInflaterFactory;
}
@Override
public void dynamicAddView(View view, List<DynamicAttr> pDAttrs) {
mThemeInflaterFactory.dynamicAddThemeEnableView(this, view, pDAttrs);
}
@Override
public void dynamicAddView(View view, String attrName, int attrValueResId, boolean isOnlyDark) {
mThemeInflaterFactory.dynamicAddThemeEnableView(this, view, attrName, attrValueResId, isOnlyDark);
}
@Override
public void onThemeUpdate() {
mThemeInflaterFactory.applyTheme();
changeStatusColor();
}
@Override
protected void onDestroy() {
super.onDestroy();
AppThemeManager.getInstance().removeObserver(this);
mThemeInflaterFactory.clean();
}
}
注意 setFactory2()一定要在 super.onCreate(savedInstanceState)之前調(diào)用即setContentView之前設(shè)置Factory2
3.在需要切換主題的根布局上添加 <code>xmlns:theme="http://schemas.android.com/android/theme" </code>,然后在需要切換主題的View上加上 <code>theme:enable="true" </code>嘉栓,注意<code>theme:onlyDark="true" </code>的使用場(chǎng)景是紅色主題 和金色主題都使用的同一資源宏榕,不需要區(qū)分,只區(qū)分暗黑資源侵佃。不寫默認(rèn)為false
4.資源文件下創(chuàng)建對(duì)應(yīng)資源區(qū)分colors.xml麻昼、colors-night.xml、colors_gold.xml馋辈、colors_gold_night.xml 或者mipmap_img.png抚芦、mipmap_img_night.png、mipmap_img_gold.png迈螟、mipmap_img_gold_night.png
具體獲取的方法看參考ThemeResourceUtil
主題屬性擴(kuò)展
默認(rèn)支持 textColor 和 background叉抡、src的主題切換。如果你還需要對(duì)其他屬性進(jìn)行主題切換答毫,需要去自定義了
比如 TabLayout它下面會(huì)有一個(gè)指示器褥民,當(dāng)我們換主題的時(shí)候也希望這個(gè)指示器的顏色也跟著更改。
- 第一步
public class TabLayoutIndicatorAttr extends BaseAttr {
@Override
public void applyTheme(View view) {
if (view instanceof TabLayout) {
TabLayout tl = (TabLayout) view;
if (RES_TYPE_NAME_COLOR.equals(attrValueTypeName)) {
int color = ThemeResourceUtil.getColor(attrValueRefId);
tl.setSelectedTabIndicatorColor(color);
}
}
}
}
- 第二步
方法中加入<code> ThemeConfig.addSupportAttr("tabLayoutIndicator", new TabLayoutIndicatorAttr());</code> - 最后我們就可以正常使用了洗搂,<code>dynamicAddView(tablayout, "tabLayoutIndicator", R.color.colorPrimaryDark);</code>
dynamicAddView:當(dāng)動(dòng)態(tài)創(chuàng)建的View也需要主題切換的時(shí)候,就可以調(diào)用dynamicAddView
注意事項(xiàng)
- 主題切換默認(rèn)只支持android的常用控件消返,支持庫的控件和自定義控件需要?jiǎng)討B(tài)添加(如: <code>dynamicAddView(toolbar, "background", R.color.colorPrimaryDark);</code>),在布局文件中使用<code>theme:enable="true"</code>是無效的
- 默認(rèn)不支持狀態(tài)欄顏色的更改蚕脏,如果需要主題切換的同時(shí)也要更改狀態(tài)欄顏色侦副,在Application中配置<code>ThemeConfig.setCanChangeStatusColor(true);</code>,狀態(tài)欄的顏色值來源于<code>colorPrimaryDark</code>
3.有主題切換需求 View 所使用的資源一定要是引用值驼鞭,如:@color/red秦驯,而不是 #ff0000