Android 插件式多主題切換原理精解

pgddjorptsw 7年前發布 | 7K 次閱讀 安卓開發 Android開發 移動開發

換膚方案原理在網上已經很多了, 這里不再詳細描述, 強迫癥的我總是想讓提供給別人使用的SDK盡量好用, 哪怕是給自己帶來額外的工作量, 經過一段時間的奮斗, 實現了一個自我感覺良好的換膚框架.

這里主要來看看 Android 源碼中”com.android.support:appcompat-v7”包的實現, 以及源碼思想在Android-skin-support中的應用 – 如何打造一款好用的換膚框架.

appcompat-v7包實現

首先來看一下源碼的實現:

AppCompatActivity源碼

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);
    }
    ....
}

AppCompatActivity 將大部分生命周期委托給了AppCompatDelegate

再看看相關的類圖

AppCompateDelegate的子類AppCompatDelegateImplV9

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");
            }
        }
    }
}

從這可以看出通過實現LayoutInflaterFactory接口來實現換膚至少可以支持到api 9以上

網上很多換膚框架的實現, 通過LayoutInflater.setFactory的方式, 在回調的onCreateView中解析每一個View的attrs, 判斷是否有已標記需要換膚的屬性, 比方說background, textColor, 或者說相應資源是否為skin_開頭等等.

然后保存到map中, 對每一個View做for循環去遍歷所有的attr, 想要對更多的屬性進行換膚, 需要Activity實現接口, 將需要換膚的View, 以及相應的屬性收集到一起

那么是不是能夠尋求一種讓使用者更方便的方式來實現, 做一個侵入性盡量小的框架呢?

本著開發者應有的好奇心, 深入的研究了一些v7包的實現

AppCompatDelegateImplV9中, 在LayoutInflaterFactory的接口方法onCreateView 中將View的創建交給了AppCompatViewInflater

@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 */
    );
}

再來看一下AppCompatViewInflater中createView的實現

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;
}

再看一下其中一個類AppCompatTextView的實現

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);
        }
    }
    ......
}

AppCompatBackgroundHelper.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);
        }
    }
    ......
}

到這里我仿佛是發現了新大陸一樣興奮, 源碼中可以通過攔截View創建過程, 替換一些基礎的組件, 然后對一些特殊的屬性(eg: background, textColor) 做處理, 那我們為什么不能將這種思想拿到換膚框架中來使用呢?

Android-skin-support換膚框架實現

抱著試一試不會少塊肉的心情, 開始了我的換膚框架開發之路

先簡單講一下原理:

1. 參照源碼實現在Activity onCreate中為LayoutInflater setFactory, 將View的創建過程交給自定義的SkinCompatViewInflater類來實現

2. 重寫系統組件, 實現換膚接口, 表明該控件支持換膚, 并在View創建之后統一收集

3. 在重寫的View中解析出需要換膚的屬性, 并保存ResId到成員變量

4. 重寫類似setBackgroundResource方法, 解析需要換膚的屬性, 并保存變量

5. applySkin 在切換皮膚的時候, 從皮膚資源中獲取資源

下面說一個簡單的例子(SkinCompatTextView):

1. 實現SkinCompatSupportable接口

2. 在構造方法中通過SkinCompatBackgroundHelper和SkinCompatTextHelper分別解析出background, textColor并保存

3. 重寫setBackgroundResource和setTextAppearance, 解析出對應的資源Id, 表明該控件支持從代碼中設置資源, 且支持該資源換膚

4. 在用戶點擊切換皮膚時調用applySkin方法設置皮膚

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);
        }
    }
}

開發過程中遇到的一些問題

在5.0以上, 使用color為ImageView設置src, 可以通過getColorStateList獲取資源, 而在5.0以下, 需要通過ColorDrawable setColor的方式實現

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);
}

還有很多問題, 有興趣的同學可以來一起交流解決.

  1. 這樣的做法與網上其他框架相比優勢在哪里, 為什么重復造輪子

    • 在增加框架開發成本的基礎上降低了框架使用的成本, 我覺得更有意義, 一次開發, 所有Android 開發者都受用;
    • 換膚框架對業務代碼的侵入性比較小, 業務代碼只需要繼承自SkinCompatActivity, 不需要實現接口重寫方法, 不需要其他額外的代碼, 接入方便, 假如將來不想再使用本框架, 只需要把SkinCompatActivity改為原生Activity即可;
    • 深入源碼, 和源碼實現方式類似, 兼容性更好.
  2. 為什么選擇繼承自AppCompatActivity, AppCompatTextView…而不是選擇直接繼承自Activity, TextView…

    • 本身appcompat-v7包是一個support包, 兼容原生控件, 同時符合Material design, 我們只需要獲取我們想要換膚的屬性就可以在不破壞support包屬性的前提下進行換膚;
    • 參與開發的同學更多的話, 完全可以支持一套繼承自Activity, TextView…的skin support包.
  3. 自定義View能否支持, 第三方控件是否支持換膚

    • 答案是肯定的, 完全可以參照SkinCompatTextView的實現, 自己去實現自定義控件, 對于使用者來說, 擴展性很好.

 

 

來自:https://juejin.im/entry/58bfd8168ac24700635cf8c4

 

 本文由用戶 pgddjorptsw 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!