1. 程式人生 > >Android 兩行程式碼實現換膚 從appcompat-v7原理出發

Android 兩行程式碼實現換膚 從appcompat-v7原理出發

背景

換膚方案原理在網上已經很多了, 這裡不再詳細描述, 強迫症的我總是想讓提供給別人使用的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); } .... }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

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");
            }
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

從這可以看出通過實現LayoutInflaterFactory介面來實現換膚至少可以支援到api 9以上

網上很多換膚框架的實現, 通過LayoutInflater.setFactory的方式, 在回撥的onCreateView中解析每一個View的attrs, 判斷是否有已標記需要換膚的屬性, 比方說background, textColor, 或者說相應資源是否為skin_開頭等等. 
然後儲存到map中, 對每一個View做for迴圈去遍歷所有的attr, 想要對更多的屬性進行換膚, 需要Activity實現介面, 將需要換膚的View, 以及相應的屬性收集到一起 
那麼是不是能夠尋求一種讓使用者更方便的方式來實現, 做一個侵入性儘量小的框架呢?

本著開發者應有的好奇心, 深入的研究了一些v7包的實現 
onCreate
setContentView
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 */
    );
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

再來看一下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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

再看一下其中一個類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);
        }
    }
    ......
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

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);
        }
    }
    ......
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

到這裡我彷彿是發現了新大陸一樣興奮, 原始碼中可以通過攔截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);
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120

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

在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
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

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

總結

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

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

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

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