[貝聊科技]談談 iOS 如何動態切換 APP 的主題

貝聊科技發表於2017-08-03

在移動網際網路的下半場,越來越多的 APP 更加註重使用者體驗,以期來打動使用者。主題的切換就是可以增強使用者體驗、結合運營活動的一個點:譬如 QQ 的夜間模式,節日裡電商 APP 的皮膚切換等等的這些小細節往往就是贏得使用者尊重的根本。本文將從主題的動態切換出發,介紹下貝聊 iOS 客戶端在實現主題動態所採用的方案,供讀者參考。

從切換方案說起

讓 APP 已有的控制元件能切換主題可以用子類化,swizzle 或 category 來實現,其中子類化和 category 實現起來差不多,都是讓控制元件調特定的方法達到切換風格的效果,而 swizzle 的影響範圍會比較廣,使用的時候可以通過 Associated Object 新增一個標記值,讓需要切換風格的控制元件設定這個標記值,讓標記值來決定是否需要 swizzle。考慮到上述幾種方案的複雜度,最後選擇了 category 來實現。

@implementation UILabel (BLTheme)

- (void)bl_setThemeTextColor {
    NSString *hexColorString = [BLThemeManager sharedInstance].styleModel.navTextColor;
    UIColor *color;
    if (hexColorString) {
        color = [UIColor colorFromHexString:hexColorString];
    }

    if (color) {
        [self setTextColor:color];
    }

}

@end複製程式碼

簡單說來就是通過已配置的描述檔案,在 category 內部讀取了下配置的樣式資料(樣式資料可能為預設樣式或自定義樣式)。

怎樣實現一個主題管理類

主題管理類的核心功能就是負責主題的更新,切換。正如下圖所示,想讓主題管理類通知到這麼多待切換的 category 並不是一件容易的事,因為覺得在 category 上新增觀察者並不是太好的設計,你很難知道什麼時機該把觀察者移除了。

難道說得改成子類化的實現,然後 ovrride dealloc 方法,可惜這樣做感覺就沒那麼純粹了。

這也就意味著,可能需要自己動手來實現回撥機制了,讓切換主題相關的 category 通過主題管理類註冊一個回撥 block,主題類維護使用一個字典維護這些 block,待切換時由主題管理類統一回撥,達到類似 Notification 的效果。

先來看看 categroy 使用此方案後的變化,仍然是剛才的 UILabel 類:

- (void)bl_setThemeTextColor {
    NSString *hexColorString = [BLThemeManager sharedInstance].styleModel.navTextColor;
    UIColor *color;
    if (hexColorString) {
        color = [UIColor colorFromHexString:hexColorString];
    }

    if (color) {
        [self setTextColor:color];
    }

    @weakify(self)
    SwitchThemeBlock switchThemeBlock = ^{
        @strongify(self)
        [self bl_setThemeTextColor];
    };
    [[BLThemeManager sharedInstance] addObserveKey:[self keyWithSelector:@selector(bl_setThemeTextColor)] withSwitchThemBlock:switchThemeBlock];
}複製程式碼

只是在方法的底部新增了註冊 block 的方法,而註冊 block 的方法也十分簡單,只需依據 key 判斷下是否需要將 block 加入程式碼中。

// BLThemeManager.m
- (void)addObserveKey:(BLThemeMapModel *)key withSwitchThemBlock:(SwitchThemeBlock)block {
    if (block) {
        NSArray *allKeys = [self.blockDictionary allKeys];

        if (![allKeys containsObject:key]) {
            self.blockDictionary[key] = block;
        }
    }
}複製程式碼

那麼問題來了,到底該如何設計一個這樣的 key 呢?

  1. 同一個控制元件的主題 category 有多個需要切換主題的方法(例如 UIButton 有setTitleColor:forState: 和 setImage:forState:);
  2. 多個控制元件都是通過同一個 categroy 來切換主題(例如有多個 UIButton 需要切換主題);

其實統籌來看,就是如何通過某個類的例項和所需定製主題的方法來確定一個 key。

一開始很自然的拼了一個類的地址和方法名來作為key [NSString stringWithFormat:@"%p#%@", class, NSStringFromSelector(selector)]

流程能跑起來了,但是問題也很明顯,只知道一個物件的指標字串,根據物件是否被釋放而進行的字典清理將變得難以實現:

// BLThemeManager.m
- (void)updateTheme {
    for (BLThemeMapModel *key in [self.blockDictionary allKeys]) {
        id object = key.target;
        if (object) {
            if ([object respondsToSelector:NSSelectorFromString(key.selectorName)]) {
                SwitchThemeBlock block = self.blockDictionary[key];
                if (block) {
                    block();
                }
            }

        } else {
            [self.blockDictionary removeObjectForKey:key];
        }
    }
}複製程式碼

那麼,應該怎樣設計 block 對應的 key 呢?

  1. 能從 key 中獲取到註冊的類;
  2. key 中也存有方法做 key 的唯一性和物件訪問該方法安全性的校驗 respondsToSelector

為此,實現了一個輔助的 model,用以訪問需要註冊的物件例項和方法名,同時作為 Dictionary 的 key,它還需要實現 NSCoping 協議:

@interface BLThemeMapModel : NSObject <NSCopying>

@property (nonatomic, weak, readonly) id target;
@property (nonatomic, copy, readonly) NSString *selectorName;

- (instancetype)initWithTarget:(id)target selctorName:(NSString *)selectorName;

@end複製程式碼

weak 修飾的物件例項能夠在物件被釋放後自動置 nil,下面附上最初的 .m 檔案實現。

@implementation BLThemeMapModel

- (instancetype)initWithTarget:(id)target selctorName:(NSString *)selectorName {
    if (self = [super init]) {
        _target = target;
        _selectorName = selectorName;
    }
    return self;
}

- (BOOL)isEqual:(id)object {
    BLThemeMapModel *model = (BLThemeMapModel *)object;

    if (model) {
        if([self.target isEqualToString:model.target] &&
           [self.selectorName isEqualToString:model.selectorName]){
            return YES;
        }

    }
    return NO;
}

- (NSUInteger)hash {
    NSUInteger hash = [self.target hash] ^ [self.selectorName hash];
    return hash;
}

- (id)copyWithZone:(NSZone *)zone {
    BLThemeMapModel *themeModel = [[BLThemeMapModel allocWithZone:zone] initWithTarget:self.target selctorName:self.selectorName];
    return themeModel;
}

@end複製程式碼

可惜自測後發現一個挺莫名的 bug,最後除錯了好一會才解決。細心的讀者可以先想想看~

因為 target 可能被置 nil,從而引起同一個 key 的 hash 值被修改了,然後在遍歷字典時,就無法取到之前加入字典的物件了,即便物件是被釋放了,但仍有個 dirty 的 BLThemeMapModel 物件在字典裡。

定位問題後其實就很好辦了,在初始化方法中新增兩個用以 hash 的屬性:

_pointerString = [NSString stringWithFormat:@"%p", target];
_targetTypeName = NSStringFromClass([target class]);複製程式碼

最後使用這兩個屬性完成 hash 和 -isEqual: 方法:

- (BOOL)isEqual:(id)object {
    BLThemeMapModel *model = (BLThemeMapModel *)object;

    if (model) {
        if([self.pointerString isEqualToString:model.pointerString] &&
           [self.selectorName isEqualToString:model.selectorName] &&
           [self.targetTypeName isEqualToString:model.targetTypeName]){
            return YES;
        }

    }
    return NO;
}

- (NSUInteger)hash {
    NSUInteger hash = [self.pointerString hash] ^ [self.selectorName hash] ^ [self.targetTypeName hash];
    return hash;
}複製程式碼

至此,動態切換主題的功能大致就實現了,而且沒使用到 OC 的任何動態方法。

總結

本文描述了實現一個主題管理類的大致思路,希望能對讀者有所幫助。後來筆者想到既然有了 target 和 selector,能不能通過 NSInvocation 來動態呼叫,就不借助 block 來回撥了,在嘗試中筆者 NSInvocation 的效率的確會低一點,而且沒有 block 靈活:

@implementation UIViewController (BLTheme)

- (UIStatusBarStyle)bl_setPreferredStatusBarStyle {
    NSInteger statusValue = [BLThemeManager sharedInstance].styleModel.statusBarColor;

    @weakify(self)
    SwitchThemeBlock switchThemeBlock = ^{
        @strongify(self)
        [self setNeedsStatusBarAppearanceUpdate];
    };
    [[BLThemeManager sharedInstance] addObserveKey:[self keyWithSelector:@selector(bl_setPreferredStatusBarStyle)] withSwitchThemBlock:switchThemeBlock];

    if (statusValue == 1) {
        return UIStatusBarStyleLightContent;
    } else {
        return UIStatusBarStyleDefault;
    }
}

@end複製程式碼

就像這,邏輯上並不期望再調一次 bl_setPreferredStatusBarStyle,而是僅僅呼叫一下 [self setNeedsStatusBarAppearanceUpdate]; 用 block 可以很靈活的指定好需要呼叫什麼方法。

或許,也可以通過實現一個 weak proxy 的方式使用 Notification 來實現,筆者就沒有嘗試了,感興趣的讀者可以試試。

相關文章