天天看點

成熟的夜間模式解決方案

還是把人家的轉載過來了,寫得好多了

原文位址再貼一遍:http://draveness.me/night/

關注倉庫,及時獲得更新:iOS-Source-Code-Analyze

從開始寫 DKNightVersion 這個架構到現在已經将近一年了,目前整個架構的設計也趨于穩定。

其實夜間模式的實作就是相當于多主題加顔色管理。而最新版本的 DKNightVersion 已經很好的解決了這個問題。

在正式介紹目前版本的實作之前,我會先簡單介紹一下 1.0 時代的 DKNightVersion 的實作,為各位讀者帶來一些新的思路,也确實想梳理一下這個架構是如何演變的。

我們會以對 backgroundColor 為例說明整個架構的工作原理。

成熟的夜間模式解決方案

方法調劑的版本

如何在不改變原有的架構,甚至不改變原有的代碼的基礎上,為應用優雅地添加夜間模式成為很多開發者不得不面對的問題。這也是 1.0 時代的 DKNightVersion 想要實作的目标。

其核心思路就是使用方法調劑修改 backgroundColor 的存取方法。

使用 nightBackgroundColor

在思考之後,我想到,想要在不改動原有代碼的基礎上實作夜間模式隻能通過在分類中添加 nightBackgroundColor 屬性,并且使用方法調劑改變 backgroundColor 的 setter 方法。

- (void)hook_setBackgroundColor:(UIColor*)backgroundColor {
    if ([DKNightVersionManager currentThemeVersion] == DKThemeVersionNormal) {
        [self setNormalBackgroundColor:backgroundColor];
    }
    [self hook_setBackgroundColor:backgroundColor];
}
           

在目前主題為 DKThemeVersionNormal 時,将顔色儲存至 normalBackgroundColor 中,然後再調用原 backgroundColor 的 setter 方法,更新視圖的顔色。

DKNightVersionManager

這裡隻解決了顔色設定的問題,下面會說明,如果在主題改變時,實時更新顔色,而不用重新進入目前頁面。

整個 DKNightVersion 都是由一個 DKNightVersionManager 的單例來管理的,而它的主要工作就是負責改變應用的主題、并在主題改變時通知其它視圖更新顔色:

- (void)changeColor:(id <DKNightVersionChangeColorProtocol>)object {
    if ([object respondsToSelector:@selector(changeColor)]) {
        [object changeColor];
    }
    if ([object respondsToSelector:@selector(subviews)]) {
        if (![object subviews]) {
            // Basic case, do nothing.
            return;
        } else {
            for (id subview in [object subviews]) {
                // recursive darken all the subviews of current view.
                [self changeColor:subview];
                if ([subview respondsToSelector:@selector(changeColor)]) {
                    [subview changeColor];
                }
            }
        }
    }
}
           

如果主題更新,那麼就會遞歸地調用 changeColor 方法,重新整理全部的視圖顔色,而這個方法的實作比較簡單:

- (void)changeColor {
    if ([DKNightVersionManager currentThemeVersion] == DKThemeVersionNormal) {
        self.backgroundColor = self.normalBackgroundColor;
    } else {
        self.backgroundColor = self.nightBackgroundColor;
    }
}
           

上面就是整個架構在 1.0 版本時的實作思路。不過這個版本的 DKNightVersion 在實際應用中會有比較多的問題:

在高速滾動的 scrollView 上面來回切換夜間模式,會出現顔色錯亂的問題

由于對 backgroundColor 屬性進行不合适的方法調劑,其行為無法預測,比如:在設定顔色後,再取出,不一定與設定時傳入的顔色相同

無法适配第三方 UI 控件

使用色表的版本

為了解決 1.0 中的各種問題,我決定在 2.0 版本中放棄對 nightBackgroundColor 的使用,并且重新設計底層的實作,轉而使用更為穩定、安全的方法實作夜間模式,先看一下效果圖:

新的實作不僅能夠支援夜間模式,而且能夠支援多主題。

DKColorPicker

與上一個版本實作上的不同,在 2.0 中删除了全部的 nightBackgroundColor,使用一個名為 dk_backgroundColorPicker 的屬性取代它。

@property (nonatomic, copy) DKColorPicker dk_backgroundColorPicker;
           

這個屬性其實就是一個 block,它接收參數 DKThemeVersion themeVersion,但是會傳回一個 UIColor :

在第一次傳入 picker 或者每次主題改變時,都會将目前主題 DKThemeVersion 傳入 picker 并執行,然後,将得到的 UIColor 指派給對應的屬性 backgroundColor 更新視圖顔色。

typedef UIColor *(^DKColorPicker)(DKThemeVersion *themeVersion);  
           

比如下面使用 DKColorPickerWithRGB 建立一個臨時的 DKColorPicker:

在 DKThemeVersionNormal 時傳回 0xffffff

在 DKThemeVersionNight 時傳回 0x343434

在自定義的主題下傳回 0xfafafa (這裡的順序與色表中主題的順序有關)

同時,每一個對象還持有一個 pickers 數組,來存儲自己的全部 DKColorPicker:

@interface NSObject ()

@property (nonatomic, strong) NSMutableDictionary<NSString *, DKColorPicker> *pickers;

@end
           

在第一次使用這個屬性時,目前對象注冊為 DKNightVersionThemeChangingNotificaiton 通知的觀察者。

在每次收到通知時,都會調用 night_update 方法,将目前主題傳入 DKColorPicker,并再次執行,并将結果傳入對應的屬性 [self performSelector:sel withObject:result]。

- (void)night_updateColor {
    [self.pickers enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull selector, DKColorPicker  _Nonnull picker, BOOL * _Nonnull stop) {
        SEL sel = NSSelectorFromString(selector);
        id result = picker(self.dk_manager.themeVersion);
        [UIView animateWithDuration:DKNightVersionAnimationDuration
                         animations:^{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
                             [self performSelector:sel withObject:result];
#pragma clang diagnostic pop
                         }];
    }];
}
           

也就是說,在每次改變主題的時候,都會發出通知。

DKColorTable

雖然我們在上面臨時建立了一些 DKColorPicker。不過在 DKNightVersion 中,我更推薦使用色表,來減少相同的 DKColorPicker 的建立,并且能夠更好地管理整個應用中的顔色:

NORMAL   NIGHT    RED  
#ffffff  #343434  #fafafa BG
#aaaaaa  #313131  #aaaaaa SEP
#0000ff  #ffffff  #fa0000 TINT
#000000  #ffffff  #000000 TEXT
#ffffff  #444444  #ffffff BAR
           

上面就是預設色表檔案 DKColorTable.txt 中的内容,其中,第一行表示主題,NORMAL 主題必須存在,而且必須為第一列,而最右面的 BG、SEP 就是對應 DKColorPicker 的 key。

self.tableView.dk_backgroundColorPicker = DKColorPickerWithKey(BG);

在使用時,上面的代碼就相當于傳回了一個在 NORMAL 時傳回 #ffffff、NIGHT 時傳回 #343434 以及 RED 時傳回 #fafafa 的 DKColorPicker。

pickerify

雖然說,我們使用色表以及 DKColorPicker 解決了,但是,到目前為止我們還沒有解決第三方架構的問題。

比如我們使用了某個第三方架構,或者自己添加了某個 color 屬性,比如說:

@interface DKView ()

@property (nonatomic, strong) UIColor *weirdColor;

@end
           

weirdColor 并沒有對應的 DKColorPicker,但是,我們可以通過 pickerify 在想要使用 dk_weirdColorPicker 的地方生成這個對應的 picker:

@pickerify(DKView, weirdColor);

然後,我們就可以使用 dk_weirdColorPicker 屬性了:

view.dk_weirdColorPicker = DKColorPickerWithKey(BG);

pickerify 其實是一個宏:

#define pickerify(KLASS, PROPERTY) interface \
    KLASS (Night) \
    @property (nonatomic, copy, setter = dk_set ## PROPERTY ## Picker:) DKColorPicker dk_ ## PROPERTY ## Picker; \
    @end \
    @interface \
    KLASS () \
    @property (nonatomic, strong) NSMutableDictionary<NSString *, DKColorPicker> *pickers; \
    @end \
    @implementation \
    KLASS (Night) \
    - (DKColorPicker)dk_ ## PROPERTY ## Picker { \
        return objc_getAssociatedObject(self, @selector(dk_ ## PROPERTY ## Picker)); \
    } \
    - (void)dk_set ## PROPERTY ## Picker:(DKColorPicker)picker { \
        objc_setAssociatedObject(self, @selector(dk_ ## PROPERTY ## Picker), picker, OBJC_ASSOCIATION_COPY_NONATOMIC); \
        [self setValue:picker(self.dk_manager.themeVersion) forKeyPath:@keypath(self, PROPERTY)];\
        [self.pickers setValue:[picker copy] forKey:_DKSetterWithPROPERTYerty(@#PROPERTY)]; \
    } \
    @end
           

這個宏根據傳入的類和屬性名,為我們生成了對應 picker 的存取方法,它也可以說是一種元程式設計的手段。

這裡生成的 setter 方法不是标準意義上的駝峰命名法 dk_setweirdColorPicker:,因為我不知道怎麼才能讓大寫首字母之後的屬性添加到這裡(如果各位讀者有解決方案,歡迎提 PR 或者 issue)。

嵌入式 Ruby

由于架構中很多的代碼,都是重複的,是以在這裡使用了嵌入式 Ruby 模闆來生成對應的檔案 color.m.irb:

//
//  <%= klass.name %>+Night.m
//  <%= klass.name %>+Night
//
//  Copyright (c) 2015 Draveness. All rights reserved.
//
//  These files are generated by ruby script, if you want to modify code
//  in this file, you are supposed to update the ruby code, run it and
//  test it. And finally open a pull request.

#import "<%= klass.name %>+Night.h"
#import "DKNightVersionManager.h"
#import <objc/runtime.h>

@interface <%= klass.name %> ()

@property (nonatomic, strong) NSMutableDictionary<NSString *, DKColorPicker> *pickers;

@end

@implementation <%= klass.name %> (Night)

<% klass.properties.each do |property| %><%= """  
- (DKColorPicker)dk_#{property.name}Picker {
    return objc_getAssociatedObject(self, @selector(dk_#{property.name}Picker));
}

- (void)dk_set#{property.cap_name}Picker:(DKColorPicker)picker {
    objc_setAssociatedObject(self, @selector(dk_#{property.name}Picker), picker, OBJC_ASSOCIATION_COPY_NONATOMIC);
    self.#{property.name} = picker(self.dk_manager.themeVersion);
    [self.pickers setValue:[picker copy] forKey:@\"#{property.setter}\"];
}
""" %><% end %>

@end
           

這部分的實作并不在這篇文章的讨論範圍之内,如果,對這部分看興趣,可以看一下倉庫中的 generator 檔案夾,其中包含了代碼生成器的全部代碼。

小結

如果你對 DKNightVersion 的使用有興趣,可以檢視倉庫的 README 檔案,有人會說不要在項目中 ObjC runtime,我個人覺得是沒有問題,AFNetworking、 BlocksKit 也使用方法調劑來改變原有方法的實作,不能因為它強大就不使用它;正相反,有時候,使用 runtime 才能優雅地解決問題。

繼續閱讀