iOS項目組件化歷程

爲何要組件化

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

若是全部業務都在同一個App中,而且同時開發人數較少時,拋開代碼健壯性不談,實際的開發體驗可能並無那麼糟糕,畢竟做爲一個開發,什麼地方用什麼控件,就跟在HashMap中經過Key獲取Value那麼簡單。github

那麼當業務成長到須要分化到多個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將它們抽象出來。ui

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

基類與Category

基類並不雞肋,合理使用,能夠減小不少的重複代碼,好比ViewController對StatusBar的控制,NavigationController對NavBar的控制。url

這種全局均可能會用到的方法適合抽象到基類或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];
	}];
}
複製代碼

到這一步調用者依賴RouterRouter經過NSInvocationCEEditInfoRouteMap通訊,CEEditInfoRouteMap依賴CEEditCompanyInfoViewController

Router成爲了單獨的組件,沒有依賴。

參考資料

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

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

相關文章
相關標籤/搜索