Android 插件式多主题切换原理精解
pgddjorptsw
8年前
<p>换肤方案原理在网上已经很多了, 这里不再详细描述, 强迫症的我总是想让提供给别人使用的SDK尽量好用, 哪怕是给自己带来额外的工作量, 经过一段时间的奋斗, 实现了一个自我感觉良好的换肤框架.</p> <p>这里主要来看看 Android 源码中”com.android.support:appcompat-v7”包的实现, 以及源码思想在Android-skin-support中的应用 – 如何打造一款好用的换肤框架.</p> <h2>appcompat-v7包实现</h2> <p>首先来看一下源码的实现:</p> <p>AppCompatActivity源码</p> <pre> <code class="language-java">public class AppCompatActivity extends FragmentActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { final AppCompatDelegate delegate = getDelegate(); delegate.installViewFactory(); delegate.onCreate(savedInstanceState); ... } @Override public MenuInflater getMenuInflater() { return getDelegate().getMenuInflater(); } @Override public void setContentView(@LayoutRes int layoutResID) { getDelegate().setContentView(layoutResID); } @Override public void setContentView(View view) { getDelegate().setContentView(view); } .... }</code></pre> <p>AppCompatActivity 将大部分生命周期委托给了AppCompatDelegate</p> <p>再看看相关的类图</p> <p><img src="https://simg.open-open.com/show/9b195055c467d442e29e93f741d7a1fa.png"></p> <p>AppCompateDelegate的子类AppCompatDelegateImplV9</p> <pre> <code class="language-java">class AppCompatDelegateImplV9 extends AppCompatDelegateImplBase implements MenuBuilder.Callback, LayoutInflaterFactory { @Override public void installViewFactory() { LayoutInflater layoutInflater = LayoutInflater.from(mContext); if (layoutInflater.getFactory() == null) { LayoutInflaterCompat.setFactory(layoutInflater, this); } else { if (!(LayoutInflaterCompat.getFactory(layoutInflater) instanceof AppCompatDelegateImplV9)) { Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed" + " so we can not install AppCompat's"); } } } }</code></pre> <p>从这可以看出通过实现LayoutInflaterFactory接口来实现换肤至少可以支持到api 9以上</p> <p>网上很多换肤框架的实现, 通过LayoutInflater.setFactory的方式, 在回调的onCreateView中解析每一个View的attrs, 判断是否有已标记需要换肤的属性, 比方说background, textColor, 或者说相应资源是否为skin_开头等等.</p> <p>然后保存到map中, 对每一个View做for循环去遍历所有的attr, 想要对更多的属性进行换肤, 需要Activity实现接口, 将需要换肤的View, 以及相应的属性收集到一起</p> <p>那么是不是能够寻求一种让使用者更方便的方式来实现, 做一个侵入性尽量小的框架呢?</p> <p>本着开发者应有的好奇心, 深入的研究了一些v7包的实现</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/f2540ae3746b834ae7d4de81eafc7bd1.png"> <img src="https://simg.open-open.com/show/5fd9629ff2db59acb7c7070974ba49a1.png"></p> <p>AppCompatDelegateImplV9中, 在LayoutInflaterFactory的接口方法onCreateView 中将View的创建交给了AppCompatViewInflater</p> <pre> <code class="language-java">@Override public final View onCreateView(View parent, String name, Context context, AttributeSet attrs) { // First let the Activity's Factory try and inflate the view final View view = callActivityOnCreateView(parent, name, context, attrs); if (view != null) { return view; } // If the Factory didn't handle it, let our createView() method try return createView(parent, name, context, attrs); } @Override public View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) { final boolean isPre21 = Build.VERSION.SDK_INT < 21; if (mAppCompatViewInflater == null) { mAppCompatViewInflater = new AppCompatViewInflater(); } // We only want the View to inherit its context if we're running pre-v21 final boolean inheritContext = isPre21 && shouldInheritContext((ViewParent) parent); return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext, isPre21, /* Only read android:theme pre-L (L+ handles this anyway) */ true, /* Read read app:theme as a fallback at all times for legacy reasons */ VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */ ); }</code></pre> <p>再来看一下AppCompatViewInflater中createView的实现</p> <pre> <code class="language-java">public final View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs, boolean inheritContext, boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) { ...... View view = null; switch (name) { case "TextView": view = new AppCompatTextView(context, attrs); break; case "ImageView": view = new AppCompatImageView(context, attrs); break; case "Button": view = new AppCompatButton(context, attrs); break; case "EditText": view = new AppCompatEditText(context, attrs); break; case "Spinner": view = new AppCompatSpinner(context, attrs); break; case "ImageButton": view = new AppCompatImageButton(context, attrs); break; case "CheckBox": view = new AppCompatCheckBox(context, attrs); break; ...... } ...... return view; }</code></pre> <p>再看一下其中一个类AppCompatTextView的实现</p> <pre> <code class="language-java">public class AppCompatTextView extends TextView implements TintableBackgroundView { public AppCompatTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(TintContextWrapper.wrap(context), attrs, defStyleAttr); mBackgroundTintHelper = new AppCompatBackgroundHelper(this); mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr); mTextHelper = AppCompatTextHelper.create(this); mTextHelper.loadFromAttributes(attrs, defStyleAttr); mTextHelper.applyCompoundDrawablesTints(); } @Override public void setBackgroundResource(@DrawableRes int resId) { super.setBackgroundResource(resId); if (mBackgroundTintHelper != null) { mBackgroundTintHelper.onSetBackgroundResource(resId); } } ...... }</code></pre> <p>AppCompatBackgroundHelper.java</p> <pre> <code class="language-java">void loadFromAttributes(AttributeSet attrs, int defStyleAttr) { TintTypedArray a = TintTypedArray.obtainStyledAttributes(mView.getContext(), attrs, R.styleable.ViewBackgroundHelper, defStyleAttr, 0); ...... if (a.hasValue(R.styleable.ViewBackgroundHelper_android_background)) { mBackgroundResId = a.getResourceId( R.styleable.ViewBackgroundHelper_android_background, -1); ColorStateList tint = mDrawableManager .getTintList(mView.getContext(), mBackgroundResId); if (tint != null) { setInternalBackgroundTint(tint); } } ...... }</code></pre> <p>到这里我仿佛是发现了新大陆一样兴奋, 源码中可以通过拦截View创建过程, 替换一些基础的组件, 然后对一些特殊的属性(eg: background, textColor) 做处理, 那我们为什么不能将这种思想拿到换肤框架中来使用呢?</p> <h2>Android-skin-support换肤框架实现</h2> <p>抱着试一试不会少块肉的心情, 开始了我的换肤框架开发之路</p> <p>先简单讲一下原理:</p> <p>1. 参照源码实现在Activity onCreate中为LayoutInflater setFactory, 将View的创建过程交给自定义的SkinCompatViewInflater类来实现</p> <p>2. 重写系统组件, 实现换肤接口, 表明该控件支持换肤, 并在View创建之后统一收集</p> <p>3. 在重写的View中解析出需要换肤的属性, 并保存ResId到成员变量</p> <p>4. 重写类似setBackgroundResource方法, 解析需要换肤的属性, 并保存变量</p> <p>5. applySkin 在切换皮肤的时候, 从皮肤资源中获取资源</p> <p>下面说一个简单的例子(SkinCompatTextView):</p> <p>1. 实现SkinCompatSupportable接口</p> <p>2. 在构造方法中通过SkinCompatBackgroundHelper和SkinCompatTextHelper分别解析出background, textColor并保存</p> <p>3. 重写setBackgroundResource和setTextAppearance, 解析出对应的资源Id, 表明该控件支持从代码中设置资源, 且支持该资源换肤</p> <p>4. 在用户点击切换皮肤时调用applySkin方法设置皮肤</p> <pre> <code class="language-java">public interface SkinCompatSupportable { void applySkin(); } public class SkinCompatTextView extends AppCompatTextView implements SkinCompatSupportable { public SkinCompatTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); mBackgroundTintHelper = new SkinCompatBackgroundHelper(this); mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr); mTextHelper = new SkinCompatTextHelper(this); mTextHelper.loadFromAttributes(attrs, defStyleAttr); } @Override public void setBackgroundResource(@DrawableRes int resId) { super.setBackgroundResource(resId); if (mBackgroundTintHelper != null) { mBackgroundTintHelper.onSetBackgroundResource(resId); } } @Override public void setTextAppearance(Context context, int resId) { super.setTextAppearance(context, resId); if (mTextHelper != null) { mTextHelper.onSetTextAppearance(context, resId); } } @Override public void applySkin() { if (mBackgroundTintHelper != null) { mBackgroundTintHelper.applySkin(); } if (mTextHelper != null) { mTextHelper.applySkin(); } } } public class SkinCompatTextHelper extends SkinCompatHelper { private static final String TAG = SkinCompatTextHelper.class.getSimpleName(); private final TextView mView; private int mTextColorResId = INVALID_ID; private int mTextColorHintResId = INVALID_ID; public SkinCompatTextHelper(TextView view) { mView = view; } public void loadFromAttributes(AttributeSet attrs, int defStyleAttr) { final Context context = mView.getContext(); // First read the TextAppearance style id TintTypedArray a = TintTypedArray.obtainStyledAttributes(context, attrs, R.styleable.SkinCompatTextHelper, defStyleAttr, 0); final int ap = a.getResourceId(R.styleable.SkinCompatTextHelper_android_textAppearance, INVALID_ID); SkinLog.d(TAG, "ap = " + ap); a.recycle(); if (ap != INVALID_ID) { a = TintTypedArray.obtainStyledAttributes(context, ap, R.styleable.SkinTextAppearance); if (a.hasValue(R.styleable.SkinTextAppearance_android_textColor)) { mTextColorResId = a.getResourceId(R.styleable.SkinTextAppearance_android_textColor, INVALID_ID); SkinLog.d(TAG, "mTextColorResId = " + mTextColorResId); } if (a.hasValue(R.styleable.SkinTextAppearance_android_textColorHint)) { mTextColorHintResId = a.getResourceId( R.styleable.SkinTextAppearance_android_textColorHint, INVALID_ID); SkinLog.d(TAG, "mTextColorHintResId = " + mTextColorHintResId); } a.recycle(); } // Now read the style's values a = TintTypedArray.obtainStyledAttributes(context, attrs, R.styleable.SkinTextAppearance, defStyleAttr, 0); if (a.hasValue(R.styleable.SkinTextAppearance_android_textColor)) { mTextColorResId = a.getResourceId(R.styleable.SkinTextAppearance_android_textColor, INVALID_ID); SkinLog.d(TAG, "mTextColorResId = " + mTextColorResId); } if (a.hasValue(R.styleable.SkinTextAppearance_android_textColorHint)) { mTextColorHintResId = a.getResourceId( R.styleable.SkinTextAppearance_android_textColorHint, INVALID_ID); SkinLog.d(TAG, "mTextColorHintResId = " + mTextColorHintResId); } a.recycle(); applySkin(); } public void onSetTextAppearance(Context context, int resId) { final TintTypedArray a = TintTypedArray.obtainStyledAttributes(context, resId, R.styleable.SkinTextAppearance); if (a.hasValue(R.styleable.SkinTextAppearance_android_textColor)) { mTextColorResId = a.getResourceId(R.styleable.SkinTextAppearance_android_textColor, INVALID_ID); SkinLog.d(TAG, "mTextColorResId = " + mTextColorResId); } if (a.hasValue(R.styleable.SkinTextAppearance_android_textColorHint)) { mTextColorHintResId = a.getResourceId(R.styleable.SkinTextAppearance_android_textColorHint, INVALID_ID); SkinLog.d(TAG, "mTextColorHintResId = " + mTextColorHintResId); } a.recycle(); applySkin(); } public void applySkin() { mTextColorResId = checkResourceId(mTextColorResId); if (mTextColorResId != INVALID_ID) { ColorStateList color = SkinCompatResources.getInstance().getColorStateList(mTextColorResId); mView.setTextColor(color); } mTextColorHintResId = checkResourceId(mTextColorHintResId); if (mTextColorHintResId != INVALID_ID) { ColorStateList color = SkinCompatResources.getInstance().getColorStateList(mTextColorHintResId); mView.setHintTextColor(color); } } }</code></pre> <h2>开发过程中遇到的一些问题</h2> <p>在5.0以上, 使用color为ImageView设置src, 可以通过getColorStateList获取资源, 而在5.0以下, 需要通过ColorDrawable setColor的方式实现</p> <pre> <code class="language-java">String typeName = mView.getResources().getResourceTypeName(mSrcResId); if ("color".equals(typeName)) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { int color = SkinCompatResources.getInstance().getColor(mSrcResId); Drawable drawable = mView.getDrawable(); if (drawable instanceof ColorDrawable) { ((ColorDrawable) drawable.mutate()).setColor(color); } else { mView.setImageDrawable(new ColorDrawable(color)); } } else { ColorStateList colorStateList = SkinCompatResources.getInstance().getColorStateList(mSrcResId); Drawable drawable = mView.getDrawable(); DrawableCompat.setTintList(drawable, colorStateList); mView.setImageDrawable(drawable); } } else if ("drawable".equals(typeName)) { Drawable drawable = SkinCompatResources.getInstance().getDrawable(mSrcResId); mView.setImageDrawable(drawable); }</code></pre> <p>还有很多问题, 有兴趣的同学可以来一起交流解决.</p> <ol> <li> <p>这样的做法与网上其他框架相比优势在哪里, 为什么重复造轮子</p> <ul> <li>在增加框架开发成本的基础上降低了框架使用的成本, 我觉得更有意义, 一次开发, 所有Android 开发者都受用;</li> <li>换肤框架对业务代码的侵入性比较小, 业务代码只需要继承自SkinCompatActivity, 不需要实现接口重写方法, 不需要其他额外的代码, 接入方便, 假如将来不想再使用本框架, 只需要把SkinCompatActivity改为原生Activity即可;</li> <li>深入源码, 和源码实现方式类似, 兼容性更好.</li> </ul> </li> <li> <p>为什么选择继承自AppCompatActivity, AppCompatTextView…而不是选择直接继承自Activity, TextView…</p> <ul> <li>本身appcompat-v7包是一个support包, 兼容原生控件, 同时符合Material design, 我们只需要获取我们想要换肤的属性就可以在不破坏support包属性的前提下进行换肤;</li> <li>参与开发的同学更多的话, 完全可以支持一套继承自Activity, TextView…的skin support包.</li> </ul> </li> <li> <p>自定义View能否支持, 第三方控件是否支持换肤</p> <ul> <li>答案是肯定的, 完全可以参照SkinCompatTextView的实现, 自己去实现自定义控件, 对于使用者来说, 扩展性很好.</li> </ul> </li> </ol> <p> </p> <p> </p> <p>来自:https://juejin.im/entry/58bfd8168ac24700635cf8c4</p> <p> </p>