一、换肤方案
目前,市面上Android的换肤方案主要有Resource方案和AssetManager替换方案两种方案。
其中,Resource方案是用户提前自定义一些主题,然后将指定主题对应的 id 设置成默认的主题即可。而AssetManager替换方案,使用的是Hook系统AssetMananger对象,然后再编译期静态对齐资源文件对应的id数值。
1.1 Resource方案
Resource方案的原理大概如下:
1、创建新的Resrouce对象(代理的Resource)
2、替换系统Resource对象
3、运行时动态映射(原理相同资源在不同的资源表中的Type和Name一样)
4、xml布局解析拦截(xml布局中的资源不能通过代理Resource加载,LayoutInflater)
此方案的优势是支持String/Layout的替换,不过缺点也很明显:
- 资源获取效率有影响
- 不支持style、asset目录
-
Resource多出替换,Resource包装类代码量大
1.2 AssetManager方案
使用的是Hook系统AssetMananger对象,然后再编译期静态对齐资源文件对应的id数值,达到替换资源的目的。此种方案,最常见的就是Hook LayoutInflater进行换肤。
二、Resource换肤
此种方式采用的方案是:用户提前自定义一些主题,然后当设置主题的时候将指定主题对应的 id 记录到本地文件中,当 Activity RESUME 的时候,判断 Activity 当前的主题是否和之前设置的主题一致,不一致的话就调用当前 Activity 的recreate()方法进行重建。
比如,在这种方案中,我们可以通过如下的方式预定义一些属性:
然后,在自定义主题中使用为这些预定义属性赋值。
最后,在布局文件中通过如下的方式引用这些自定义属性。
三、Hook LayoutInflater方案
3.1 工作原理
通过 Hook LayoutInflater 进行换肤的方案是众多开源方案中比较常见的一种。在分析这种方案之前,我们最好先了解下 LayoutInflater 的工作原理。通常,当我们想要自定义 Layout 的 Factory 的时候可以调用下面两个方法将我们的 Factory 设置到系统的 LayoutInflater 中。
public abstract class LayoutInflater {
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);
}
}
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);
}
}
}
当我们调用 inflator()方法从 xml 中加载布局的时候,将会走到如下代码真正执行加载操作。
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
// ....
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();
// 处理 merge 标签
if (TAG_MERGE.equals(name)) {
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// 从 xml 中加载布局控件
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
// 生成布局参数 LayoutParams
ViewGroup.LayoutParams params = null;
if (root != null) {
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
temp.setLayoutParams(params);
}
}
// 加载子控件
rInflateChildren(parser, temp, attrs, true);
// 添加到根控件
if (root != null && attachToRoot) {
root.addView(temp, params);
}
if (root == null || !attachToRoot) {
result = temp;
}
}
} catch (XmlPullParserException e) {/*...*/}
return result;
}
}
接下来,我们看一下createViewFromTag()方法。
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) {
// 老的布局方式
if (name.equals("view")) {
name = attrs.getAttributeValue(null, "class");
}
// 处理 theme
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) {
// ...
}
}
public final View tryCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (name.equals(TAG_1995)) {
return new BlinkLayout(context, attrs);
}
// 优先使用 mFactory2 创建 view,mFactory2 为空则使用 mFactory,否则使用 mPrivateFactory
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;
}
可以看出,这里优先使用 mFactory2 创建 view,mFactory2 为空则使用 mFactory,否则使用 mPrivateFactory 加载 view。所以,如果我们想要对 view 创建过程进行 hook,就应该 hook 这里的 mFactory2,因为它的优先级最高。
注意到这里的 方法中并没有循环,所以,第一次的时候只能加载根布局。那么根布局内的子控件是如何加载的呢?这就用到了inflaterInflateChildren()方法。
final void rInflateChildren(XmlPullParser parser, View parent, AttributeSet attrs,
boolean finishInflate) throws XmlPullParserException, IOException {
rInflate(parser, parent, parent.getContext(), attrs, finishInflate);
}
void rInflate(XmlPullParser parser, View parent, Context context, AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
final int depth = parser.getDepth();
int type;
boolean pendingRequestFocus = false;
while (((type = parser.next()) != XmlPullParser.END_TAG || parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
if (type != XmlPullParser.START_TAG) continue;
final String name = parser.getName();
if (TAG_REQUEST_FOCUS.equals(name)) {
// 处理 requestFocus 标签
pendingRequestFocus = true;
consumeChildElements(parser);
} else if (TAG_TAG.equals(name)) {
// 处理 tag 标签
parseViewTag(parser, parent, attrs);
} else if (TAG_INCLUDE.equals(name)) {
// 处理 include 标签
if (parser.getDepth() == 0) {
throw new InflateException(" cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
} else if (TAG_MERGE.equals(name)) {
// 处理 merge 标签
throw new InflateException(" must be the root element");
} else {
// 这里处理的是普通的 view 标签
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
// 继续处理子控件
rInflateChildren(parser, view, attrs, true);
viewGroup.addView(view, params);
}
}
if (pendingRequestFocus) {
parent.restoreDefaultFocus();
}
if (finishInflate) {
parent.onFinishInflate();
}
}
注意到该方法内部又调用了createViewFromTag和rInflateChildren方法,也就是说,这里通过递归的方式实现对整个 view 树的遍历,从而将整个 xml 加载为 view 树。以上是安卓的 LayoutInflater 从 xml 中加载控件的逻辑,可以看出我们可以通过 hook 实现对创建 view 的过程的“监听”。
上面我们说了下换肤的原理,下面我们介绍几种Android换肤的技术框架:Android-Skin-Loader、ThemeSkinning和Android-skin-support。
3.2 Android-Skin-Loader
3.2.1 使用流程
学习了 Hook LayoutInflator 的底层原理之后,我们来看几个基于这种原理实现的换肤方案。首先是 Android-Skin-Loader 这个库,这个库需要你覆写Activity,然后再替换皮肤,Activity部分代码如下。
public class BaseActivity extends Activity implements ISkinUpdate, IDynamicNewView{
private SkinInflaterFactory mSkinInflaterFactory;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mSkinInflaterFactory = new SkinInflaterFactory();
getLayoutInflater().setFactory(mSkinInflaterFactory);
}
// ...
}
可以看出,这里将自定义的 Factory 设置给了LayoutInflator,SkinInflaterFactory的实现如下:
public class SkinInflaterFactory implements Factory {
private static final boolean DEBUG = true;
private List mSkinItems = new ArrayList();
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
// 读取自定义属性 enable,这里用了自定义的 namespace
boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
if (!isSkinEnable){
return null;
}
// 创建 view
View view = createView(context, name, attrs);
if (view == null){
return null;
}
parseSkinAttr(context, attrs, view);
return view;
}
private View createView(Context context, String name, AttributeSet attrs) {
View view = null;
try {
// 兼容低版本创建 view 的逻辑(低版本是没有完整包名)
if (-1 == name.indexOf('.')){
if ("View".equals(name)) {
view = LayoutInflater.from(context).createView(name, "android.view.", attrs);
}
if (view == null) {
view = LayoutInflater.from(context).createView(name, "android.widget.", attrs);
}
if (view == null) {
view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs);
}
} else {
// 新的创建 view 的逻辑
view = LayoutInflater.from(context).createView(name, null, attrs);
}
} catch (Exception e) {
view = null;
}
return view;
}
private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
List viewAttrs = new ArrayList();
// 对 xml 中控件的属性进行解析
for (int i = 0; i
这里自定义了一个 xml 属性,用来指定是否启用换肤配置。然后在创建 view 的过程中解析 xml 中定义的 view 的属性信息,比如,background 和 textColor 等属性。并将其对应的属性、属性值和控件以映射的形式记录到缓存中。当发生换肤的时候根据这里的映射关系在代码中更新控件的属性信息。
public class BackgroundAttr extends SkinAttr {
@Override
public void apply(View view) {
if(RES_TYPE_NAME_COLOR.equals(attrValueTypeName)){
// 注意这里获取属性值的时候是通过 SkinManager 的方法获取的
view.setBackgroundColor(SkinManager.getInstance().getColor(attrValueRefId));
}else if(RES_TYPE_NAME_DRAWABLE.equals(attrValueTypeName)){
Drawable bg = SkinManager.getInstance().getDrawable(attrValueRefId);
view.setBackground(bg);
}
}
}
如果是动态添加的 view,比如在 java 代码中,该库提供了 等方法来动态添加映射关系到缓存中。在 activity 的生命周期方法中注册监听换肤事件:
public class BaseActivity extends Activity implements ISkinUpdate, IDynamicNewView{
@Override
protected void onResume() {
super.onResume();
SkinManager.getInstance().attach(this);
}
@Override
protected void onDestroy() {
super.onDestroy();
SkinManager.getInstance().detach(this);
// 清理缓存数据
mSkinInflaterFactory.clean();
}
@Override
public void onThemeUpdate() {
if(!isResponseOnSkinChanging){
return;
}
mSkinInflaterFactory.applySkin();
}
// ...
}
当换肤的时候会通知到 Activity 并触发onThemeUpdate方法,接着调用 SkinInflaterFactory 的 apply 方法。SkinInflaterFactory 的 apply 方法中对缓存的属性信息遍历更新实现换肤。
3.2.2 皮肤包加载逻辑
接下来,我们看一下皮肤包的加载逻辑,即通过自定义的 AssetManager 实现,类似于插件化。
public void load(String skinPackagePath, final ILoaderListener callback) {
new AsyncTask() {
protected void onPreExecute() {
if (callback != null) {
callback.onStart();
}
};
@Override
protected Resources doInBackground(String... params) {
try {
if (params.length == 1) {
String skinPkgPath = params[0];
File file = new File(skinPkgPath);
if(file == null || !file.exists()){
return null;
}
PackageManager mPm = context.getPackageManager();
PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
skinPackageName = mInfo.packageName;
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);
Resources superRes = context.getResources();
Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());
SkinConfig.saveSkinPath(context, skinPkgPath);
skinPath = skinPkgPath;
isDefaultSkin = false;
return skinResource;
}
return null;
} catch (Exception e) { /*...*/ }
};
protected void onPostExecute(Resources result) {
mResources = result;
if (mResources != null) {
if (callback != null) callback.onSuccess();
notifySkinUpdate();
}else{
isDefaultSkin = true;
if (callback != null) callback.onFailed();
}
};
}.execute(skinPackagePath);
}
然后,在获取值的时候使用下面的方法:
public int getColor(int resId){
int originColor = context.getResources().getColor(resId);
if(mResources == null || isDefaultSkin){
return originColor;
}
String resName = context.getResources().getResourceEntryName(resId);
int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
int trueColor = 0;
try{
trueColor = mResources.getColor(trueResId);
}catch(NotFoundException e){
e.printStackTrace();
trueColor = originColor;
}
return trueColor;
}
3.2.3 方案特点
此种方案换肤,有如下的一些特点:
- 换肤需要继承自定义 activity
- 皮肤包和 APK 如果使用了资源混淆加载的时候就会出现问题
- 没处理属性值通过 的形式引用的情况?attr
- 每个换肤的属性需要自己注册并实现
- 有些控件的一些属性可能没有提供对应的 java 方法,因此在代码中换肤就行不通
- 没有处理使用 style 的情况
- 基于 实现,版本太老android.app.Activity
-
在 inflator 创建 view 的时候,其实只做了对属性的拦截处理操作,可以通过代理系统的 Factory 实现创建 view 的操作
3.3 ThemeSkinning
这个库是基于上面的 Android-Skin-Loader 开发的,在其基础之上做了许多的调整,其地址是 ThemeSkinning。主要调整的内容如下:
3.3.1 AppCompactActivity调整
该库基于 AppCompactActivity 和LayoutInflaterCompat.setFactory开发,改动的内容如下:
public class SkinBaseActivity extends AppCompatActivity implements ISkinUpdate, IDynamicNewView {
private SkinInflaterFactory mSkinInflaterFactory;
private final static String TAG = "SkinBaseActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
mSkinInflaterFactory = new SkinInflaterFactory(this);
LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
super.onCreate(savedInstanceState);
changeStatusColor();
}
// ...
}
同时,该库也提供了修改状态栏的方法,虽然能力比较有限。
3.3.2 SkinInflaterFactory调整
SkinInflaterFactory对创建View做了一些调整,代码如下:
public class SkinInflaterFactory implements LayoutInflater.Factory2 {
private Map mSkinItemMap = new HashMap();
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);
AppCompatDelegate delegate = mAppCompatActivity.getDelegate();
View view = delegate.createView(parent, name, context, attrs);
// 对字体兼容做了支持,这里是通过静态方式将其缓存到内存,动态新增和移除,加载字体之后调用 textview 的 settypeface 方法替换
if (view instanceof TextView && SkinConfig.isCanChangeFont()) {
TextViewRepository.add(mAppCompatActivity, (TextView) view);
}
if (isSkinEnable || SkinConfig.isGlobalSkinApply()) {
if (view == null) {
// 创建 view 的逻辑做了调整
view = ViewProducer.createViewFromTag(context, name, attrs);
}
if (view == null) {
return null;
}
parseSkinAttr(context, attrs, view);
}
return view;
}
// ...
}
以下是View的创建逻辑的相关代码:
class ViewProducer {
private static final Object[] mConstructorArgs = new Object[2];
private static final Map> sConstructorMap = new ArrayMap();
private static final Class>[] sConstructorSignature = new Class[]{Context.class, AttributeSet.class};
private static final String[] sClassPrefixList = {"android.widget.", "android.view.", "android.webkit."};
static View createViewFromTag(Context context, String name, AttributeSet attrs) {
if (name.equals("view")) {
name = attrs.getAttributeValue(null, "class");
}
try {
// 构造参数,缓存,复用
mConstructorArgs[0] = context;
mConstructorArgs[1] = attrs;
if (-1 == name.indexOf('.')) {
for (int i = 0; i
3.3.3 对style的兼容处理
private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
List viewAttrs = new ArrayList();
for (int i = 0; i
3.3.4 fragment 调整
在 Fragment 的生命周期方法结束的时候从缓存当中移除指定的 View。
@Override
public void onDestroyView() {
removeAllView(getView());
super.onDestroyView();
}
protected void removeAllView(View v) {
if (v instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) v;
for (int i = 0; i
这种方案相对第一个框架改进了很多,但是此库已经有4,5年没有维护了,组件和代码都比较老。
3.4 Android-skin-support
接下来,我们再看一下Android-skin-support 。主要修改的部分如下:
3.4.1 自动注册 layoutinflator.factory
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
private SkinActivityLifecycle(Application application) {
application.registerActivityLifecycleCallbacks(this);
installLayoutFactory(application);
// 注册监听
SkinCompatManager.getInstance().addObserver(getObserver(application));
}
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
if (isContextSkinEnable(activity)) {
installLayoutFactory(activity);
// 更新 acitvity 的窗口的背景
updateWindowBackground(activity);
// 触发换肤...如果 view 没有创建是不是就容易导致 NPE?
if (activity instanceof SkinCompatSupportable) {
((SkinCompatSupportable) activity).applySkin();
}
}
}
private void installLayoutFactory(Context context) {
try {
LayoutInflater layoutInflater = LayoutInflater.from(context);
LayoutInflaterCompat.setFactory2(layoutInflater, getSkinDelegate(context));
} catch (Throwable e) { /* ... */ }
}
// 获取 LayoutInflater.Factory2,这里加了一层缓存
private SkinCompatDelegate getSkinDelegate(Context context) {
if (mSkinDelegateMap == null) {
mSkinDelegateMap = new WeakHashMap();
}
SkinCompatDelegate mSkinDelegate = mSkinDelegateMap.get(context);
if (mSkinDelegate == null) {
mSkinDelegate = SkinCompatDelegate.create(context);
mSkinDelegateMap.put(context, mSkinDelegate);
}
return mSkinDelegate;
}
// ...
}
LayoutInflaterCompat.setFactory2()方法源码如下:
public final class LayoutInflaterCompat {
public static void setFactory2(LayoutInflater inflater, LayoutInflater.Factory2 factory) {
inflater.setFactory2(factory);
if (Build.VERSION.SDK_INT
3.4.2 LayoutInflater.Factory2
public class SkinCompatDelegate implements LayoutInflater.Factory2 {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = createView(parent, name, context, attrs);
if (view == null) return null;
// 加入缓存
if (view instanceof SkinCompatSupportable) {
mSkinHelpers.add(new WeakReference((SkinCompatSupportable) view));
}
return view;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
View view = createView(null, name, context, attrs);
if (view == null) return null;
// 加入缓存,继承这个接口的主要是 view 和 activity 这些
if (view instanceof SkinCompatSupportable) {
mSkinHelpers.add(new WeakReference((SkinCompatSupportable) view));
}
return view;
}
public View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) {
// view 生成逻辑被包装成了 SkinCompatViewInflater
if (mSkinCompatViewInflater == null) {
mSkinCompatViewInflater = new SkinCompatViewInflater();
}
List wrapperList = SkinCompatManager.getInstance().getWrappers();
for (SkinWrapper wrapper : wrapperList) {
Context wrappedContext = wrapper.wrapContext(mContext, parent, attrs);
if (wrappedContext != null) {
context = wrappedContext;
}
}
//
return mSkinCompatViewInflater.createView(parent, name, context, attrs);
}
// ...
}
3.4.3 SkinCompatViewInflater
上述方法中 SkinCompatViewInflater 获取 view 的逻辑如下。
public final View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) {
// 通过 inflator 创建 view
View view = createViewFromHackInflater(context, name, attrs);
if (view == null) {
view = createViewFromInflater(context, name, attrs);
}
// 根据 view 标签创建 view
if (view == null) {
view = createViewFromTag(context, name, attrs);
}
// 处理 xml 中设置的点击事件
if (view != null) {
checkOnClickListener(view, attrs);
}
return view;
}
private View createViewFromHackInflater(Context context, String name, AttributeSet attrs) {
View view = null;
for (SkinLayoutInflater inflater : SkinCompatManager.getInstance().getHookInflaters()) {
view = inflater.createView(context, name, attrs);
if (view == null) {
continue;
} else {
break;
}
}
return view;
}
private View createViewFromInflater(Context context, String name, AttributeSet attrs) {
View view = null;
for (SkinLayoutInflater inflater : SkinCompatManager.getInstance().getInflaters()) {
view = inflater.createView(context, name, attrs);
if (view == null) {
continue;
} else {
break;
}
}
return view;
}
public View createViewFromTag(Context context, String name, AttributeSet attrs) {
// 形式的 tag,和 一样
if ("view".equals(name)) {
name = attrs.getAttributeValue(null, "class");
}
try {
// 构造参数缓存
mConstructorArgs[0] = context;
mConstructorArgs[1] = attrs;
if (-1 == name.indexOf('.')) {
for (int i = 0; i
四、其他方案
除了上面介绍的方案外,还有如下的一些方案:
4.1 TG换肤方案
TG 的换肤只支持夜间和日间主题之间的切换,所以,相对上面几种方案 TG 的换肤就简单得多。
在阅读 TG 的代码的时候,我也 TG 在做页面布局的时候做了一件很疯狂的事情——他们没有使用任何 xml 布局,所有布局都是通过 java 代码实现的。
为了支持对主题的自定义 TG 把项目内几乎所有的颜色分别定义了一个名称,对以文本形式记录到一个文件中,数量非常多,然后将其放到 assets 下面,应用内通过读取这个资源文件来获取各个控件的颜色。
4.2 自定义控件 + 全局广播实现换肤
这种方案根前面 hook LayoutInflator 的自动替换视图 的方案差不多。不过,这种方案不需要做 hook,而是对应用的内常用的控件全部做一边自定义。自定义控件内部监听换肤的事件。当自定义控件接收到换肤事件的时候,自定义控件内部触发换肤逻辑。不过这种换肤的方案相对于上述通过 hook LayoutInflator 的方案而言,可控性更好一些。
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net