iOS--談一談模塊化架構(附Demo)


目錄

  • 先說說模塊化
  • 如何將中間層與業務層剝離
  • performSelector與協議的異同
  • 調用方式
  • 中間件的路由策略
  • 模塊入口
  • 低版本兼容
  • 重定向路由
  • 項目的結構
  • 模塊化的程度
  • 哪些模塊適合下沉
  • 關於協作開發
  • 效果演示

先說說模塊化

網上有很多談模塊化的文章、這裏有一篇《IOS-組件化架構漫談》有興趣可以讀讀。

總之有三個階段

MVC模式下、我們的總工程長這樣:
加一箇中間層、負責調用指定文件
將中間層與模塊進行解耦

如何將中間層與業務層剝離

  • 剛纔第二張圖裏的基本原理:

將原本在業務文件(KTHomeViewController)代碼裏的耦合代碼

KTAModuleUserViewController * vc = [[KTAModuleUserViewController alloc]initWithUserName:@"kirito" age:18];
[self.navigationController pushViewController:vc animated:YES];

轉移到中間層(KTComponentManager)中

//KTHomeViewController.h  

UIViewController * vc = [[KTComponentManager sharedInstance] ModuleA_getUserViewControllerWithUserName:@"kirito" age:18];
[self.navigationController pushViewController:vc animated:YES];

//KTComponentManager.h
return [[KTAModuleUserViewController alloc]initWithUserName:userName age:age];

看似業務之間相互解耦、但是中間層將要引用所有的業務模塊。
直接把耦合的對象轉移了而已。

  • 解耦的方式

想要解耦、前提就是不引用頭文件。
那麼、通過字符串代替頭文件的引用就是了。
簡單來講有兩種方式:

1. - (id)performSelector:(SEL)aSelector withObject:(id)object;

具體使用上

Class targetClass = NSClassFromString(@"targetName");
SEL action = NSSelectorFromString(@"ActionName");
return [target performSelector:action withObject:params];

但這樣有一個問題、就是返回值如果不爲id類型、有機率造成崩潰。
不過這可以通過NSInvocation進行彌補。
這段代碼摘自《iOS從零到一搭建組件化項目架構》

- (id)safePerformAction:(SEL)action target:(NSObject *)target params:(NSDictionary *)params
{
    NSMethodSignature* methodSig = [target methodSignatureForSelector:action];
    if(methodSig == nil) {
        return nil;
    }
    const char* retType = [methodSig methodReturnType];

    if (strcmp(retType, @encode(void)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        return nil;
    }

    if (strcmp(retType, @encode(NSInteger)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        NSInteger result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

    if (strcmp(retType, @encode(BOOL)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        BOOL result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

    if (strcmp(retType, @encode(CGFloat)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        CGFloat result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

    if (strcmp(retType, @encode(NSUInteger)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        NSUInteger result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    return [target performSelector:action withObject:params];
#pragma clang diagnostic pop
}
  1. 利用協議的方式調用未知對象方法(這也是我使用的方式)

首先你需要一個協議:

@protocol KTComponentManagerProtocol <NSObject>

+ (id)handleAction:(NSString *)action params:(NSDictionary *)params;

@end

然後調用:

if ([targetClass respondsToSelector:@selector(handleAction:params:)]) {
     //向已經註冊的對象發送Action信息
     returnObj = [targetClass handleAction:actionName params:params];
}else {
     //未註冊的、進行進一步處理。比如上報啊、返回一個佔位對象啊等等
     NSLog(@"未註冊的方法");
}

如果有返回基本類型可以在具體入口文件裏處理:

+ (id)handleAction:(NSString *)action params:(NSDictionary *)params {
    id returnValue = nil;

    if ([action isEqualToString:@"isLogin"]) {
        returnValue = @([[KTLoginManager sharedInstance] isLogin]);
    }
    if ([action isEqualToString:@"loginIfNeed"]) {
        returnValue = @([[KTLoginManager sharedInstance] loginIfNeed]);
    }
    
    if ([action isEqualToString:@"loginOut"]) {
        [[KTLoginManager sharedInstance] loginOut];
    }
    return returnValue;
}

performSelector與協議的異同

以上兩種方式的中心思想基本相同、也有許多共同點:
  1. 需要用字典方式傳遞參數
  2. 需要處理返回值爲非id的情況
    只不過一個交給路由、一個交給具體模塊。
協議相比performSelector當然也有不同:
  1. 突破了performSelector最多隻能傳遞一個參數的限制、並且你可以定製自己想要的格式
+ (id)handleAction:(NSString *)action params:(NSDictionary *)params;
  1. 具體方法的調用、協議要多一層調用
    handleAction方法根據具體的action代替performSelector進行動作的分發。

不過我還是覺得第二種方便、因爲你的performSelector與實際調用的方法、也解耦了。
比如有一天你換了方法:
performSelector的方式還需要修改整個url、以保證調用到正確的Selector
而協議則不然、你可以在handleAction方法的內部進行二次路由。


調用方式

  • 中間件調用模塊

這裏我做了兩種方案、一種純Url一種帶參

UIViewController *vc = [self openUrl:[NSString stringWithFormat:@"https://www.bilibili.com/KTModuleHandlerForA/getUserViewController?userName=%@&age=%d",userName,age]];

NSNumber *value = [self openUrl:@"ModuleHandlerForLogin/loginIfNeed" params:@{@"delegate":delegate}];

這兩種方式都會用到、區別隨後再說。

  • 模塊間調用

用上面的方式直接調用也可以、但是容易寫錯。
通過爲中間件加入Category的方式、對接口進行約束。
並且將url以及參數的拼裝工作交給對應模塊的開發人員。

@interface KTComponentManager (ModuleA)

- (UIViewController *)ModuleA_getUserViewControllerWithUserName:(NSString *)userName age:(int)age;

@end

然後直接代用中間件的Category接口

UIViewController * vc = [[KTComponentManager sharedInstance] ModuleA_getUserViewControllerWithUserName:@"kirito" age:18];
    [self.navigationController pushViewController:vc animated:YES];

中間件的路由策略

  • 遠程路由 && 降級路由
- (id)openUrl:(NSString *)url{
    id returnObj;
    
    NSURL * openUrl = [NSURL URLWithString:url];
    NSString * path = [openUrl.path substringWithRange:NSMakeRange(1, openUrl.path.length - 1)];
    
    NSRange range = [path rangeOfString:@"/"];
    NSString *targetName = [path substringWithRange:NSMakeRange(0, range.location)];
    NSString *actionName = [path substringWithRange:NSMakeRange(range.location + 1, path.length - range.location - 1)];
    
    //可以對url進行路由。比如從服務器下發json文件。將AAAA/BBBB路由到AAAA/DDDD或者CCCC/EEEE這樣
    if (self.redirectionjson[path]) {
        path = self.redirectionjson[path];
    }
    
    //如果該target的action已經註冊
    if ([self.registeredDic[targetName] containsObject:actionName]) {
        returnObj = [self openUrl:path params:[self getURLParameters:openUrl.absoluteString]];
    }else if ([self.webUrlSet containsObject:[NSString stringWithFormat:@"%@%@",openUrl.host,openUrl.path]]){
        //低版本兼容
        //如果有某些H5頁面、打開H5頁面
        //webUrlSet可以由服務器下發
        NSLog(@"跳轉網頁:%@",url);
        
    }
    
    return returnObj;
}

遠程路由需要考慮由於本地版本過低導致需要跳轉H5的情況。
如果本地支持、則直接使用本地路由。

  • 本地路由
- (id)openUrl:(NSString *)url params:(NSDictionary *)params {
    id returnObj;
    
    if (url.length == 0) {
        return nil;
    }
    
    //可以對url進行路由。比如從服務器下發json文件。將AAAA/BBBB路由到AAAA/DDDD或者CCCC/EEEE這樣
    if (self.redirectionjson[url]) {
        url = self.redirectionjson[url];
    }
    
    
    NSRange range = [url rangeOfString:@"/"];
    
    NSString *targetName = [url substringWithRange:NSMakeRange(0, range.location)];
    NSString *actionName = [url substringWithRange:NSMakeRange(range.location + 1, url.length - range.location - 1)];
    

    Class targetClass = NSClassFromString(targetName);
    
    
    if ([targetClass respondsToSelector:@selector(handleAction:params:)]) {
        //向已經實現了協議的對象發送Target&&Action信息
        returnObj = [targetClass handleAction:actionName params:params];
    }else {
        //未註冊的、進行進一步處理。比如上報啊、返回一個佔位對象啊等等
        NSLog(@"未註冊的方法");
    }

    return returnObj;
}

通過調用模塊入口模塊targetClass遵循的中間件協議方法handleAction:params:將動作action以及參數params傳遞。


模塊入口

模塊入口實現了中間件的協議方法handleAction:params:
根據不同的Action、內部自己負責邏輯處理。

#import "ModuleHandlerForLogin.h"
#import "KTLoginManager.h"
#import "KTComponentManager+LoginModule.h"

@implementation ModuleHandlerForLogin

/**
 相當於每個模塊維護自己的註冊表
 */
+ (id)handleAction:(NSString *)action params:(NSDictionary *)params {
    id returnValue = nil;
    if ([action isEqualToString:@"getUserViewController"]) {
        
        returnValue = [[KTAModuleUserViewController alloc]initWithUserName:params[@"userName"] age:[params[@"age"] intValue]];
    }
    return returnValue;
}

低版本兼容

有時低版本的App也可能被遠程進行路由、但卻並沒有原生頁面。

這時、如果有H5頁面、則需要跳轉H5

//如果該target的action已經註冊
if ([self.registeredDic[targetName] containsObject:actionName]) {
    returnObj = [self openUrl:path params:[self getURLParameters:openUrl.absoluteString]];
}else if ([self.webUrlSet containsObject:[NSString stringWithFormat:@"%@%@",openUrl.host,openUrl.path]]){
    //低版本兼容
    //如果有某些H5頁面、打開H5頁面
    //webUrlSet可以由服務器下發
    NSLog(@"跳轉網頁:%@",url);
}

registeredDic負責維護註冊表、記錄了本地模塊實現了那些Target && Action。
這個註冊動作、交給每個模塊的入口進行:

/**
 在load中向模塊管理器註冊
 
 這裏其實如果引入KTComponentManager會方便很多
 但是會依賴管理中心、所以算了
 
 */
+ (void)load {

    #pragma clang diagnostic push
    #pragma clang diagnostic ignored "-Warc-performSelector-leaks"

    Class KTComponentManagerClass = NSClassFromString(@"KTComponentManager");
    SEL sharedInstance = NSSelectorFromString(@"sharedInstance");
    id KTComponentManager = [KTComponentManagerClass performSelector:sharedInstance];
    SEL addHandleTargetWithInfo = NSSelectorFromString(@"addHandleTargetWithInfo:");
    
    NSMutableSet * actionSet = [[NSMutableSet alloc]initWithArray:@[@"getUserViewController"]];
    
    NSDictionary * targetInfo = @{
                                  @"targetName":@"KTModuleHandlerForA",
                                  @"actionSet":actionSet
                                  };
    
    [KTComponentManager performSelector:addHandleTargetWithInfo withObject:targetInfo];

    #pragma clang diagnostic pop

}

重定向路由

由於某些原因、有時我們需要修改某些Url路由的指向(比如順風車?)

//可以對url進行路由。比如從服務器下發json文件。將AAAA/BBBB路由到AAAA/DDDD或者CCCC/EEEE這樣
if (self.redirectionjson[path]) {
    path = self.redirectionjson[path];
}

這個redirectionjson由服務器下發、本地路由時如果發現有需要被重定向的Path則進行重定向動作、修改路由的目的地。


項目的結構

模塊全部以私有Pods的形式引入、單個模塊內部遵循MVC(隨便你用什麼MVP啊、MVVM啊。只要別引入其他模塊的東西)。

我只是寫一個demo、所以嫌麻煩沒有搞Pods。意會吧。


模塊化的程度

每個模塊、引入了公共模塊之後。
可以在自己的Target工程獨立運行。


哪些模塊適合下沉

可以跨產品使用的模塊

日誌、網絡層、三方SDK、持久化、分享、工具擴展等等。


關於協作開發

pods一定要保證版本的清晰、比如Category哪怕只更新了一個入口、也要當做一個新的版本。

於是開發的階段由於要經常更新代碼、最好還是不要用pods。
大家可以寫好Category在自己模塊的Target先工作。

最後調試上線的時候再統一上傳pods並且打包。


效果演示

寫了三個按鈕

- (IBAction)pushToModuleAUserVC:(UIButton *)sender {
    
    if (![[KTComponentManager sharedInstance] loginIfNeedWithDelegate:self]) {
        return;
    }
    
    UIViewController * vc = [[KTComponentManager sharedInstance] ModuleA_getUserViewControllerWithUserName:@"kirito" age:18];
    [self.navigationController pushViewController:vc animated:YES];
    
}
- (IBAction)LoginBtnClick:(UIButton *)sender {
    
    if ([[KTComponentManager sharedInstance] loginIfNeedWithDelegate:self]) {
        [[KTComponentManager sharedInstance] loginOutWithDelegate:self];
    }
    
}

- (IBAction)openWebUrl:(id)sender {
    [[KTComponentManager sharedInstance] openUrl:[NSString stringWithFormat:@"https://www.bilibili.com/video/av25305807"]];
}

//這裏應該用通知獲取的
- (void)didLoginIn {
    [self.loginBtn setTitle:@"退出登錄" forState:UIControlStateNormal];
}

- (void)didLoginOut {
    [self.loginBtn setTitle:@"登錄" forState:UIControlStateNormal];
}



Demo


最後

本文主要是自己的學習與總結。如果文內存在紕漏、萬望留言斧正。如果願意補充以及不吝賜教小弟會更加感激。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章