android 實現【夜晚模式】的另一種思路

源碼地址

在一切開始以前,我只想用正當的方式,跪求各位的一個star
clipboard.pngjava

https://github.com/geminiwen/skin-spriteandroid

預覽
previewgit

在寫SegmentFault for Android 4.0的過程當中,由於原先採用的夜間模式,代碼着實很差看,因而我又開始挖坑了。github

在幾個月前更新的Android Support Library 23.2中,讓咱們認識到了DayNight Theme。一看源碼,原來之前在API 8的時候就已經有了night相關的資源能夠設置,只是以前一直不知道怎麼使用,後來發現原來仍是利用了AssetManager相關的API —— Android在指定條件下加載指定文件夾中的資源。 這正是我想要的! 這樣咱們只用指定好引用的資源,(好比@color/colorPrimary) 那麼我就能夠在白天加載values/color.xml中的資源,晚上加載values-night/color.xml中的資源。緩存

白天加載values的資源,晚上加載values-night的資源

v7已經幫咱們完成了這裏的功能,放置夜晚資源的問題也已經解決了,但是每次切換DayNight模式的時候,須要重啓下Activity,這件事情很讓人討厭,緣由就是由於重啓後,咱們的Context就會從新建立,View也會從新建立,根據當前系統(應用)配置的不一樣,加載不一樣的資源。 那咱們有沒有可能作到不重啓Activity來實現夜間模式呢?其實實現方案很簡單:咱們只用記錄好系統渲染xml的時候,當時給View的資源id,在特定時刻,從新加載這些資源,而後設置給View便可。接下去咱們碰到兩個問題:app

  1. 在引入這個庫的狀況下,讓開發者少改已有的xml文件,把全部的佈局都換爲咱們指定的佈局。ide

  2. API要儘可能簡單,清楚,明白。函數

上面兩個條件提及來很容易,其實想實現並非很容易的,還好AppCompat給了我一些思路。佈局

來自AppCompat的啓發

當咱們引入appcompat-v7,有了AppCompatActivity的時候,咱們發現咱們渲染的TextView/Button等組件分別變成了AppCompatTextViewAppCompatButton, 這些組件都是包含在v7包中的,很早之前以爲很神奇,當看了AppCompatActivityAppCompatDelegate的源碼,知道了LayoutInflator.Factory這些東西的工做原理以後,這一切也就不神奇了 —— 它只是在inflate的過程當中,注入了本身的代碼進去,好比把TextView解析成AppCompatTextView類,達到對解析結果攔截的目的。spa

OK,藉助這個方法,咱們能夠在Activity.onCreate中,注入咱們本身的LayoutInflatorFactory

clipboard.png

像這樣,有興趣的同窗能夠看看AppCompatDelegateImplV7這個類的installViewFactory方法的實現。
接下去咱們的目的是把TextViewButton等類換成咱們本身的實現——SkinnableTextViewSkinnableButton
能夠翻到AppCompatViewInflater這個類的源碼,其實很清晰了:

public 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 = 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;
            case "RadioButton":
                view = new AppCompatRadioButton(context, attrs);
                break;
            case "CheckedTextView":
                view = new AppCompatCheckedTextView(context, attrs);
                break;
            case "AutoCompleteTextView":
                view = new AppCompatAutoCompleteTextView(context, attrs);
                break;
            case "MultiAutoCompleteTextView":
                view = new AppCompatMultiAutoCompleteTextView(context, attrs);
                break;
            case "RatingBar":
                view = new AppCompatRatingBar(context, attrs);
                break;
            case "SeekBar":
                view = new AppCompatSeekBar(context, attrs);
                break;
        }

        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 it's android:onClick
            checkOnClickListener(view, attrs);
        }

        return view;
    }

這裏完成的工做就是把XML中的一些Tag解析爲java的類實例,咱們能夠依樣畫葫蘆,只不過把其中的AppCompatTextView換成SkinnableTextView

//省略代碼
switch (name) {
   case "TextView":
       view = new SkinnableTextView(context, attrs);
       break;
}
//省略代碼

好了,若是有須要,咱們在庫中把全部的類都替換成本身的實現,就能達到目的了,使得那些使用原始控件的開發者,不修改一絲一毫的代碼,渲染出咱們定製的控件。

應用DayNightMode

上一節咱們解決了自定義View替換原始View的問題,那麼接下去怎麼辦呢?這裏咱們一樣也參考AppCompat關於BackgroundTint的一些設計方式。首先咱們能夠看到AppComatTextView的聲明:

public class AppCompatTextView extends TextView implements TintableBackgroundView {
//...
}

實現了一個TintableBackgroundView的接口,而咱們使用ViewCompat.setSupportBackgroundTint的時候,能夠找到這麼一條:

static void setBackgroundTintList(View view, ColorStateList tintList) {
    if (view instanceof TintableBackgroundView) {
        ((TintableBackgroundView) view).setSupportBackgroundTintList(tintList);
    }
}

利用OO的特性,很輕鬆的判斷這個View是否支持咱們想要的特性,這時候我也聲明瞭一個接口Skinnable

public class SkinnableTextView extends AppCompatTextView implements Skinnable {
    //...
}

這樣等於給個人類打了一個標記,外部調用的時候,就能夠判斷這個View是否實現了咱們的接口,若是實現了接口,就能夠調用相關的函數。

咱們在Activity的基類中,能夠如此調用

private void applyDayNightForView(View view) {
    if (view instanceof Skinnable) {
        Skinnable skinnable = (Skinnable) view;
        if (skinnable.isSkinnable()) {
            skinnable.applyDayNight();
        }
    }
    if (view instanceof ViewGroup) {
        ViewGroup parent = (ViewGroup)view;
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            applyDayNightForView(parent.getChildAt(i));
        }
    }
}

利用遞歸的方式,把全部實現Skinnable接口的View所有應用了applyDayNight方法。 所以開發者使用的時候,只用把Activity的繼承改成SkinnableActivity,而後在恰當的時機調用setDayNightMode便可。

Skinnable在View中具體實現

這節講的是如何解決咱們的痛點 —— 不重啓Activity應用DayNight mode

那咱們的View實現Skinnable接口中的方法,究竟是如何工做的呢,以SkinnableTextView爲例子。
通常咱們對TextView應用的樣式有backgroundtextColor,額外的狀況下帶一個backgroundTint都是OK的。
首先咱們的大前提是,這些資源在xml中是用引用的方式傳進來的,什麼意思呢,看下面的表格

android:textColor="@color/primaryColor" android:textColor="#fff"
android:textColor="?attr/colorPrimary" android:textColor="#000"

總結起來一句話,就是不該該是絕對值,若是是絕對值的話,咱們去改它的值也不符合邏輯。

那麼若是是資源引用的方式的話,咱們使用TypedArray這個對象,是能夠獲取到咱們引用的資源的id的,也就是R.color.primaryColor的具體數值。 咱們把這個值保存下來,而後在恰當的時候,利用這個值再去變化後的Context中獲取一遍指定的顏色

ContextCompat.getColor(context, R.color.primaryColor);

這時候咱們獲取到的實際值,context就會根據系統的配置去正確的文件夾下找咱們想要的資源了。

咱們利用TypedArray能獲取到資源的id,使用TypedArray.getResourceId方法便可,傳入屬性的索引值就行。

public void storeAttributeResource(TypedArray a, int[] styleable) {
    int size = a.getIndexCount();
    for (int index = 0; index < size; index ++) {
        int resourceId = a.getResourceId(index, -1);
        int key = styleable[index];
        if (resourceId != -1) {
            mResourceMap.put(key, resourceId);
        }
    }
}

最後,在切換夜間模式的時候,咱們調用了applyDayNight方法,具體代碼以下:

@Override
public void applyDayNight() {
    Context context = getContext();
    int key;

    key = R.styleable.SkinnableView[R.styleable.SkinnableView_android_background];
    Integer backgroundResource = mAttrsHelper.getAttributeResource(key);
    if (backgroundResource != null) {
        Drawable background = ContextCompat.getDrawable(context, backgroundResource);
        //這時候獲取到的background是符合上下文的
        setBackgroundDrawable(background);
    }
    //省略代碼
}

總結以及缺陷

通過以上幾點的開發,咱們使用日/夜模式切換就變得很是容易了,好比咱們若是隻處理顏色的修改的話,只用在values/colors.xmlvalues-night/colors.xml配置好指定顏色在不一樣模式下的表現形式,再調用setDayNightMode方法,就能夠完成一鍵切換,不須要在xml中添加任何複雜凌亂的東西。

由於在配置上節省了許多代碼,那咱們的約定就變得比較冗長了,若是想進行自定義View的換膚的話,就須要手動去實現Skinnable接口,實現applyDayNight方法,開發者這時候就須要去作一些緩存資源id的操做。

同時由於它依賴於AppCompat DayNight Mode,它只能做用於日/夜間模式的切換,要想實現換膚功能,是作不到的。

這兩點是缺陷,同時也是和市面上其餘換膚庫最不一樣的地方。可是咱們把骯髒的代碼隱藏在頂部實現裏,就是爲了業務邏輯層代碼的乾淨和整潔。

但願各位會喜歡,而後有問題能夠留言或者在github上給我提PR,很是感謝。

Github Repo 地址:https://github.com/geminiwen/skin-sprite

相關文章
相關標籤/搜索