iOS專案元件化歷程

Ginhhor大帥發表於2019-03-12

為什麼要元件化

隨著業務的發展,App中的頁面,網路請求,通用彈層UI,通用TableCell數量就會劇增,需求的開發人員數量也會逐漸增多。

如果所有業務都在同一個App中,並且同時開發人數較少時,拋開程式碼健壯性不談,實際的開發體驗可能並沒有那麼糟糕,畢竟作為一個開發,什麼地方用什麼控制元件,就跟在HashMap中通過Key獲取Value那麼簡單。

那麼當業務成長到需要分化到多個App的時候,元件化的重要性開始體現了。

展示控制元件

@interface CESettingsCell : UITableViewCell

@property (strong, nonatomic) UILabel *titleLabel;
@property (strong, nonatomic) UILabel *tipsLabel;
@property (strong, nonatomic) UIImageView *arrowImgV;

@end
複製程式碼

如程式碼所示這是一個很常見TableCell,其中有標題小圖示右箭頭。將這樣的元件抽象成一個基類,後續再使用的時候,就可以直接繼承改寫,或者直接使用,能省去很多工作量。

隨著頁面的增加,這種結構會被大量的運用在其他列表之中。其實在第二相似需求出現的時候,就該考慮進行抽象的,可惜經常是忙於追趕業務,寫著寫著就給忘記了。

互動控制元件

@interface CEOptionPickerViewController : CEBaseViewController

@property (strong, nonatomic) NSArray<NSArray *> *pickerDataList;
@property (strong, nonatomic) NSMutableArray<NSNumber *> *selectedIndexList;
@property (strong, nonatomic) NSString *tipsTitle;

@property (strong, nonatomic) NSDictionary *rowAttributes;

@property (copy, nonatomic) void(^didOptionSelectedBlock) (NSArray<NSNumber *> *selectedIndexList);

@end
複製程式碼

這也是一個已經抽象好的控制元件,作用是顯示一個內容為二維陣列的選擇器,可以用來選擇省份-城市,或者年-月

這種型別的資料。

在元件中,這類一次編寫,多場景使用元件是最容易抽象的,一般在第一次開發的時候就能想到元件化。需要注意的是,這樣的元件儘量不要使用多層繼承,如果有相同特性但是不同的實現,用Protocal將它們抽象出來。

牢記Copy-Paste是埋坑的開始(哈哈哈哈哈,你會忘記哪一份程式碼是最新的,血淚教訓)。

基類與Category

基類並不雞肋,合理使用,可以減少很多的重複程式碼,比如ViewController對StatusBar的控制,NavigationController對NavBar的控制。

這種全域性都可能會用到的方法適合抽象到基類或Category中,避免重複程式碼。在抽象方法的時候一定要剋制,確認影響範圍足夠廣,實現方式比較普遍的實現才適合放入基類中,與業務相關的程式碼更需要酌情考慮。

比如一個定製化的返回鍵,在當前專案中屬於通用方案,每個導航欄頁面都用到了,但是如果新開了一個專案,是否是改個圖片就繼續用,還是連導航欄都可能自定義了呢。

這裡舉個例子,我們專案中用到了很多H5與Native的通訊,於是就抽象了一個CEBaseWebViewController專門用來管理JS的註冊與移除,以及基礎Cookie設定。

網路資料層

我們現在採用的是MVVM模式,ViewModel的分層可以讓ViewController中的資料互動都通過ViewModel來進行,ViewController與資料獲取已經完全隔離。

另外我封裝了一層網路層,用於對接服務端介面,進一步將ViewModel的網路依賴抽離出來。

// ViewController
@interface CEMyWalletViewController : CEBaseViewController

@property (strong, nonatomic) CEMyWalletViewModel *viewModel;

@end

// ViewModel
@interface CEMyWalletViewModel : NSObject

@property (assign, nonatomic) NSInteger currentPageIndex;

@property (assign, nonatomic) CEWalletBillFilterType filterType;

@property (strong, nonatomic) NSArray <CEWalletBillInfo *> *billList;

@property (strong, nonatomic) CEWallet *myWallet;

- (void)getMyWalletInfo:(BOOL)HUDVisible completion:(void(^)(BOOL success))completion;

- (void)getWalletShortBillInfoList:(void(^)(BOOL success))completion;

- (void)getWalletBillInfoList:(void(^)(BOOL success, BOOL hasMoreContent))completion;

@end

// Network
@interface CEWalletNetworking : NSObject


+ (void)getMyWalletDetail:(CENetworkingOption *)option completion:(CENetworkingCompletionBlock)completionBlock;

+ (void)getWalletShortBillList:(CENetworkingOption *)option completion:(CENetworkingCompletionBlock)completionBlock;

+ (void)getWalletBillListByPageNum:(NSInteger)pageNum billType:(CEWalletBillFilterType)billType option:(CENetworkingOption *)option completion:(CENetworkingCompletionBlock)completionBlock

@end
複製程式碼
資料傳輸路徑

Networking/Database -> ViewModel -> ViewController

用介面的形式將資料提供給ViewModelViewModel來維護ViewController的資料,ViewController只需要維護View的顯示邏輯即可。

這樣不論是服務端介面變更,還是業務邏輯變更,都不會影響到ViewController。

這裡可以抽象的元件主要是在Networking和Database這一層,比如我在Networking對AFNetworking進行了二次封裝,根據業務模組進行劃分,方便業務使用。同樣,Database我們用的是CoreData,也對其進行了二次封裝。

ViewController的路由

方案選擇

原先開發的時候,是為每一個頁面都做了Category,作為路由邏輯的封裝。缺點就是,比如像入口比較多的首頁,就需要import多個Category。

學習了下網上流行的URLRouter,Protocol-Class和Target-Action方案,最後參考了Target-Action方案(傳送門:CTMediator)的思路。

主要考慮到在後期會考慮升級成路由表,在Target-Action的排程者中加入Url方案也比較容易,引數解析已經完成,不需要重複修改。

實現方案

首先是將跳轉邏輯統一管理起來,於是就又過了GHRouter。

GHRouter的主要作用是在執行時,請求頁面的訊息通過反射的形式傳遞到正確的RouteMap上,從而執行正確的跳轉。

#import <Foundation/Foundation.h>

#define Router(targetClsName,selName,paramsDic) ([[GHRouter sharedInstance] performTargetClassName:(targetClsName) selectorName:(selName) params:(paramsDic)])

NS_ASSUME_NONNULL_BEGIN
@interface GHRouter : NSObject

/**
 用於檢測用於跳轉的Url是否為特定Url,預設不檢測
 */
@property (nonatomic, strong) NSString *openUrlScheme;
/**
 targetClass 例項快取
 */
@property (nonatomic, strong) NSMapTable *targetCache;
/**
 預設快取30個target,超過閾值後,會隨機移除一半。
 */
@property (nonatomic, assign) NSInteger maxCacheTargetCount;

/**
 預設檢測targetClassName是否以“RouteMap”結尾,賦值為nil可以關閉檢測。
 */
@property (nonatomic, strong) NSString *targetClassNameSuffix;

/**
 預設檢測selectorName是否以“routerTo”開頭,賦值為nil可以關閉檢測。
 */
@property (nonatomic, strong) NSString *selectorNamePrefix;

+ (instancetype)sharedInstance;
/**
 通過URL跳轉指定頁面
 例如:
 MyProject://TargetClassName/SelectorName:?params1="phone"&params2="name"
 或
 MyProject://TargetClassName/SelectorName?params1="phone"&params2="name"
 SelectorName後面可以不帶冒號,會自動新增。
 
 @param url 傳入的URL
 @param validate 自定義校驗過程,傳入nil,則表示不做自定義校驗
 @return 返回值
 */
- (id)performByUrl:(NSURL *)url validate:(BOOL(^)(NSURL *url))validate;
/**
 例如:
 
 在路由Class中建立以下方法,用於跳轉。
 為了規範用法,第一位引數必須傳入NSDIctionary型別的物件。
 - (UIViewController *)routerToViewController:(NSDictionary *)params;
 - (void)routerToViewController:(NSDictionary *)params;

 @param targetClassName 路由Class名稱
 @param selectorName 呼叫的路由方法
 @param params 路由引數
 @return 返回值
 */
- (id)performTargetClassName:(NSString *)targetClassName selectorName:(NSString *)selectorName params:( NSDictionary *__nullable)params;

- (void)removeTargetCacheByClassName:(NSString *)className;
- (void)cleanupTargetCache;

@end

NS_ASSUME_NONNULL_END

複製程式碼
#import <UIKit/UIKit.h>
#import "GHRouter.h"

@implementation GHRouter

+ (instancetype)sharedInstance
{
    static dispatch_once_t onceToken;
    static id sharedInstance = nil;
    
    dispatch_once(&onceToken, ^{
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}
- (instancetype)init
{
    self = [super init];
    if (self) {
        [self setup];
    }
    return self;
}

- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

- (void)setup
{
    _targetCache = [NSMapTable strongToStrongObjectsMapTable];
    _maxCacheTargetCount = 30;
    _selectorNamePrefix = @"routeTo";
    _targetClassNameSuffix = @"RouteMap";
    _openUrlScheme = nil;
    
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(cleanupTargetCache) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
}

- (id)performByUrl:(NSURL *)url validate:(BOOL(^)(NSURL *url))validate
{
    if (_openUrlScheme.length != 0) {
        if (![url.scheme isEqualToString:_openUrlScheme]) {
            return [NSNull null];
        };
    }
    
    NSString *scheme = url.scheme;
    if (scheme.length == 0) {
#ifdef DEBUG
        NSLog(@"ERROR: %s url.scheme is nil",__FUNCTION__);
#endif
        return [NSNull null];
    }
    
    NSString *targetClassName = url.host;
    if (targetClassName.length == 0) {
#ifdef DEBUG
        NSLog(@"ERROR: %s url.host is nil",__FUNCTION__);
#endif
        return [NSNull null];
    }
    
    NSString *path = url.path;
    if (path.length == 0) {
#ifdef DEBUG
        NSLog(@"ERROR: %s url.path is nil",__FUNCTION__);
#endif
        return [NSNull null];
    }
    
    if (validate) {
        if (!validate(url)) {
            return [NSNull null];
        };
    }
    
    NSMutableString *selectorName = [NSMutableString stringWithString:path];
    
    if ([selectorName hasPrefix:@"/"]) {
        [selectorName deleteCharactersInRange:NSMakeRange(0, 1)];
    }
    
    if (![selectorName hasSuffix:@":"]) {
        [selectorName stringByAppendingString:@":"];
    }
    
    NSDictionary *params = [self queryDictionary:url];
    
    return [self performTargetClassName:targetClassName selectorName:selectorName params:params];
}

- (id)performTargetClassName:(NSString *)targetClassName selectorName:(NSString *)selectorName params:(NSDictionary *)params
{
    NSAssert(targetClassName.length != 0, @"ERROR: %s \n targetClassName is nil",__FUNCTION__);
    NSAssert(selectorName.length != 0, @"ERROR: %s \n selectorName is nil",__FUNCTION__);
    NSAssert([selectorName hasSuffix:@":"], @"ERROR: %s \n selectorName (%@) must have params, such as \"routeToA:\"", __FUNCTION__, selectorName);

    if (_targetClassNameSuffix.length != 0) {
        NSAssert([targetClassName hasSuffix:_targetClassNameSuffix], @"ERROR: %s targetClassName must has suffix by \"%@\"",__FUNCTION__,_targetClassNameSuffix);
    }
    
    if (_selectorNamePrefix.length != 0) {
        NSAssert([selectorName hasPrefix:_selectorNamePrefix], @"ERROR: %s selectorName must has Prefix by \"%@\"",__FUNCTION__,_selectorNamePrefix);
    }
    
    Class targetClass = NSClassFromString(targetClassName);
    if (!targetClass) {
#ifdef DEBUG
        NSLog(@"ERROR: %s targetClass can't found by targetClassName:\"%@\"",__FUNCTION__, targetClassName);
#endif
        return [NSNull null];
    }
    
    id target = [_targetCache objectForKey:targetClassName];
    if (!target) {
        target = [[targetClass alloc] init];
    }
    
    SEL selector = NSSelectorFromString(selectorName);
    if (![target respondsToSelector:selector]) {
#ifdef DEBUG
        NSLog(@"ERROR:%s targetClassName:\"%@\" can't found selectorName:\"%@\"", __FUNCTION__, targetClassName, selectorName);
#endif
        return [NSNull null];
    }
    
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    return [self performTarget:target selector:selector params:params];
#pragma clang diagnostic pop
}

#pragma mark- Private Method

- (id)performTarget:(id)target selector:(SEL)selector params:(NSDictionary *)params
{
    NSMethodSignature *method = [target methodSignatureForSelector:selector];
    if (!method) {
        return nil;
    }
    const char *returnType = [method methodReturnType];
    
    //返回值如果非物件型別,會報EXC_BAD_ACCESS
    if (strcmp(returnType, @encode(BOOL)) == 0) {
        NSInvocation *invocation = [self invocationByMethod:method target:target selector:selector params:params];
        [invocation invoke];
        
        BOOL *result = malloc(method.methodReturnLength);
        [invocation getReturnValue:result];
        NSNumber *returnObj = @(*result);
        free(result);

        return returnObj;
    } else if (strcmp(returnType, @encode(void)) == 0) {
        NSInvocation *invocation = [self invocationByMethod:method target:target selector:selector params:params];
        [invocation invoke];
        return [NSNull null];
    } else if (strcmp(returnType, @encode(unsigned int)) == 0
               || strcmp(returnType, @encode(NSUInteger)) == 0) {
        NSInvocation *invocation = [self invocationByMethod:method target:target selector:selector params:params];
        [invocation invoke];
        
        NSUInteger *result = malloc(method.methodReturnLength);
        [invocation getReturnValue:result];
        NSNumber *returnObj = @(*result);
        free(result);
        
        return returnObj;
    } else if (strcmp(returnType, @encode(double)) == 0
               || strcmp(returnType, @encode(float)) == 0
               || strcmp(returnType, @encode(CGFloat)) == 0) {
        NSInvocation *invocation = [self invocationByMethod:method target:target selector:selector params:params];
        [invocation invoke];
        
        CGFloat *result = malloc(method.methodReturnLength);
        [invocation getReturnValue:result];
        NSNumber *returnObj = @(*result);
        free(result);
        
        return returnObj;
    } else if (strcmp(returnType, @encode(int)) == 0
               || strcmp(returnType, @encode(NSInteger)) == 0) {
        NSInvocation *invocation = [self invocationByMethod:method target:target selector:selector params:params];
        [invocation invoke];
        
        NSInteger *result = malloc(method.methodReturnLength);
        [invocation getReturnValue:result];
        NSNumber *returnObj = @(*result);
        free(result);
        
        return returnObj;
    }
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    return [target performSelector:selector withObject:params];
#pragma clang diagnostic pop
}

- (NSInvocation *)invocationByMethod:(NSMethodSignature *)method target:(id)target selector:(SEL)selector params:(NSDictionary *)params
{
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:method];
    [invocation setTarget:target];
    [invocation setSelector:selector];
    
    if (method.numberOfArguments > 2 && params) {
        [invocation setArgument:&params atIndex:2];
    }
    return invocation;
}

#pragma mark Cache

- (void)addTargetToCache:(id)target targetClassName:(NSString *)targetClassName
{
//    當快取數量達到上限的時候,會隨機刪除一半的快取
    if (_targetCache.count > _maxCacheTargetCount) {
        while (_targetCache.count > _maxCacheTargetCount/2) {
            [_targetCache removeObjectForKey:_targetCache.keyEnumerator.nextObject];
        }
    }
    [_targetCache setObject:target forKey:targetClassName];
}

- (void)removeTargetCacheByClassName:(NSString *)className
{
    [_targetCache removeObjectForKey:className];
}

- (void)cleanupTargetCache
{
    [_targetCache removeAllObjects];
}

#pragma mark- Private Method

- (NSDictionary *)queryDictionary:(NSURL *)url
{
    NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
    NSString *urlString = [url query];
    for (NSString *param in [urlString componentsSeparatedByString:@"&"]) {
        NSArray *elts = [param componentsSeparatedByString:@"="];
        if ([elts count] < 2) {
            continue;
        }
        [params setObject:[elts lastObject] forKey:[elts firstObject]];
    }
    return params;
}

@end

複製程式碼
總結下Router通訊流程

本地元件通訊

  1. Router收到請求,通過TargetClassNameSelectorName來尋找對應的Class與Selector,期間會校驗TargetClassName是否以“RouteMap”結尾,SelectorName是否以“routeTo”,以規範和區分路由類。
  2. selector可以被響應後,會建立對應Class的物件(不用靜態方法是因為靜態方法在類載入的時候就會被初始化到記憶體中,而成員方法在例項初始化時才會被載入到記憶體中,使用靜態方法會影響到啟動速度),並加入快取,通過methodSignatureForSelector獲取對應的NSMethodSignature
  3. 構建NSInvocation並加入Params
  4. 觸發NSInvocation,並獲取返回值。對返回值進行判斷,非物件型別的返回值包裝成NSNumber,無返回值型別返回nil,以防止在獲取返回值時出現Crash,或者型別出錯。
  5. 當快取的Target達到閾值時,會被釋放掉一半的快取,當收到記憶體警告時,會釋放掉所有的快取。

遠端通訊

  1. Router收到Url,先校驗Scheme,再從Url中解析出TargetClassNameSelectorNameParams
  2. 進行自定義驗證。
  3. 進入本地元件通訊流程。

這裡舉個例子:比如有一個EditCompanyInfoViewController,首先要為EditInfoRouteMap,用於解析跳轉引數。這裡要注意的是,由於引數是包裝在Dictionary中的,所以在route方法上請加上引數註釋,方便後期維護。

// .h
@interface CEEditInfoRouteMap : NSObject

/**
 跳轉公司資訊編輯頁面

 @param params @{@"completion":void (^completion)(BOOL success, UIViewController *vc)}
 */
- (void)routeToEditCompanyInfo:(NSDictionary *)params;

@end

// .m
#import "CEEditInfoRouteMap.h"
#import "CEEditCompanyInfoViewController.h"

@implementation CEEditInfoRouteMap

- (void)routeToEditCompanyInfo:(NSDictionary *)params
{
    void (^completion)(BOOL success, UIViewController *vc) = params[@"completion"];
    
    CEEditCompanyInfoViewController *vc = [[CEEditCompanyInfoViewController alloc] init];
    [vc.viewModel getCompanyInfo:^(BOOL success) {
        completion(success,vc);
    }];
}

@end

複製程式碼

再者為CERouter建立一個Category,用於管理路由構造。

// .h
#import "GHRouter.h"

@interface GHRouter (EditInfo)

- (void)routeToEditCompanyInfo:(void(^)(BOOL success, UIViewController *vc))completion;

@end
    
// .m
#import "GHRouter+EditInfo.h"

@implementation GHRouter (EditInfo)

- (void)routeToEditCompanyInfo:(void(^)(BOOL success, UIViewController *vc))completion
{
    Router(@"CEEditInfoRouteMap", @"routeToEditCompanyInfo:", @{@"completion":completion});
}

@end

複製程式碼

最終呼叫

#import "GHRouter+EditInfo.h"

- (void)editCompanyInfo
{
	[[GHRouter sharedInstance] routeToEditCompanyInfo:^(BOOL success, UIViewController * _Nonnull vc) {
		[self.navigationController pushViewController:vc animated:YES];
	}];
}
複製程式碼

iOS專案元件化歷程

到這一步呼叫者依賴RouterRouter通過NSInvocationCEEditInfoRouteMap通訊,CEEditInfoRouteMap依賴CEEditCompanyInfoViewController

Router成為了單獨的元件,沒有依賴。

參考資料

iOS 元件化之路由設計思路分析

iOS開發——元件化及去Mode化方案

相關文章