談談關於 iOS 的架構以及應用

HenryCheng發表於2019-04-26

一直以來想寫一篇文章,但是沒找到合適的主題,前段時間一直在看 Flutter 的一些東西,本來有意向想寫關於 Flutter 的一些總結,但是看的有些零零散散,並且沒有實際應用過,所以也就擱置了。正好最近一段時間除主業務之餘,一直在做我們 甘草醫生 使用者端的重構,剛好有一些對於 iOS 架構方面的看法與感悟,在這裡與大家分享。 萬事開頭難!其實在開始重構之前,我是很糾結的,一直很難開始。我也曾翻閱過很多資料,想找到一個合適的符合我們自己目前業務的架構,最後做了種種的比對與測試,選擇了 MVVM + 元件化 + AOP 的模式來重構。可能有人會疑問,你為什麼選擇這樣的架構模式?使用這些模式有什麼好處?這些抽象的模式概念具體應該怎麼在實際專案中運用?OK,那我們就帶著這些疑問一步步往下看。

關於架構模式

我們先來了解一下在 iOS 中常用的一些架構模式

  • MVC 關於 MVC(Model-View-Controller)這個設計模式我相信稍有些程式設計經驗的人都瞭解至少聽說過,作為應用最為廣泛的架構模式,大家應該都是耳熟能詳了,但是不同的人對 MVC 的理解是不同的。在 iOS 中,Cocoa Touch 框架使用的就是 MVC ,如下

    談談關於 iOS 的架構以及應用
    這是蘋果典型的 MVC 模式,使用者通過 View 將互動(點選、滑動等)通知給 Controller,Controller 收到通知後更新 Model,Model 狀態改變以後再通知 Controller 來改變他們負責的 View。由於在 iOS 中我們常用的 UIViewController 本身就自帶一個 View,所以在 iOS 開發中 Controller 層和 View 層總是緊密的耦合在一起,如果一個頁面業務邏輯量大的話,一個檢視控制器經常會很多行的程式碼,導致檢視控制器非常的臃腫。 可見,MVC 模式雖然能帶來簡單的業務分層,但是想必各位使用 MVC 模式的 iOSer 們經常會被以下幾個問題困擾

    1. 厚重的 ViewController 在日常的處理中,我們一般將我們的一些網路請求、資料儲存、檢視邏輯等一些處理全部扔在我們的 ViewController 裡,在業務量大的情況下,一個 ViewController 裡面就會有幾千行程式碼
    2. 較差的可測試性 對一個有幾千甚至上萬行的 ViewController 進行單元測試是一個非常難以接受的事情,可以說,誰接到這個任務都是難以接受的
    3. 較差的可讀性 我相信大家都有接手一個專案然後改 bug 的經歷,當你看到一個有 10000 行的程式碼的 ViewController 的時候,你肯定吐槽過
  • MVVM

    MVVM (Model-View-ViewModel),其實也是基於 MVC 的。上面我們說的 MVC 臃腫的問題,在 MVVM 的架構模式中得到了解決,我們一些常用的網路請求、資料儲存等都交給它處理,這樣就可以分離出 ViewController 裡面的一些程式碼使其“減肥”。

    談談關於 iOS 的架構以及應用
    如圖,就是 MVC 到 MVVM 的演變過程,在 MVVM 中 V 包含 View 和 ViewController,可以看出來 MVVM 其實就是把 MVC 中的 C 分離出來一個 ViewModel 用來做一些資料加工的事情。在上面 MVC 模式中講了,一個 ViewController 經常會有很多東西要處理,資料加工、網路請求等,現在都可以交給 ViewModel 去做了。這樣,Controller 就可以實現“減肥”,而更加專注於自己的資料調配的工作,繫結 ViewModel 和 View 的關係
    談談關於 iOS 的架構以及應用
    可以看出 MVVM 的模式解決了 MVC 模式中的一些問題,使得 ViewController 程式碼量減少、使得可讀性變高、程式碼單元測試變得簡單。但是 MVVM 也有其一些缺陷,比如由於 ViewModel 和 View 的繫結,那麼出現了 bug 第一時間定位不到 bug 的位置,有可能是 View 層的 bug 傳到了 Model 層。還有一點就是對於較大的工程的專案,資料的繫結和轉換需要較大的成本。關於其缺點以及可行的解決方式,在 Casa TaloyumiOS應用架構談 網路層設計方案 已經說明的比較詳細,有興趣的童鞋可以去看一下,幾篇關於架構方面的文章都很值得一讀。

  • 其他的一些架構模式

    還有一些其他的架構模式,比如 MVP(Model-View-Presenter)、VIPER(View-Interactor-Presenter-Entity-Routing)、MVCS(Model-View-Controller-Store)等,其實都是基於 MVC 思想派生出來的一些架構模式,基本都是為了給 Controller 減負而生的,所以還是那句話,萬變不離 MVC !

架構模式的選用

瞭解到每個架構模式的優缺點之後,這裡,我決定用 MVVM 的架構模式來重構我們的 APP。那麼說到 MVVM ,我們就肯定是要提到 RAC ,也就是 ReactiveCocoa,它是一個響應式程式設計的框架,可以使每層互動起來更加方便清晰。當然, RAC 肯定不是實現資料繫結的唯一方案,在 iOS 中比如 KVO、Notification、Delegate、Block等都可以實現,只不過是 RAC 的實現更加優雅一些,所以我們經常會採用 RAC 來實現資料的繫結。關於 RAC ,下面一張圖很清晰的解釋了它的思想,也就是 FRP(Function Reactive Programming)函式響應式程式設計

談談關於 iOS 的架構以及應用
上圖可以看到 c 根據 a 和 b 的值變化的過程。舉個例子,我們一般在登入的時候,會限制輸入手機號的長度,那麼按著以往的做法,就是實現 UITextField 的代理,監聽輸入文字的變化,如下

//1、匯入代理
<UITextFieldDelegate>
//2、設定代理
self.phoneTextField.delegate = self;
//3、實現代理
- (void)textFieldDidChange:(UITextField *)textField {

        if (textField == self.phoneTextField) {
            if (textField.text.length > 11) {
                textField.text = [textField.text substringToIndex:11];
            }
        }
}
複製程式碼

那麼如果使用 RAC ,如下

    @weakify(self);
    [[self.phoneTextField.rac_textSignal map:^id _Nullable(NSString * _Nullable value) {
        return value.length > 11 ? [value substringToIndex:11] : value;
    }] subscribeNext:^(NSString *x) {
        @strongify(self);
        self.phoneTextField.text = x;
    }];
複製程式碼

可以看出程式碼變得更加清晰了,我們只需要實現對 phoneTextField 訊號的監聽,就可以實現了。我們再來看一個例子,比如在我們使用者端的登入介面,如下圖

談談關於 iOS 的架構以及應用
按著正常的邏輯就是使用者輸入 11 位手機號碼後再輸入密碼才能使其登入,這個時候我們的登入按鈕才能點選,要想實現這個邏輯,正常的做法應該如下,

//1、匯入代理
<UITextFieldDelegate>
//2、設定代理
self.phoneTextField.delegate = self;
self.passwordTextField.delegate = self;
//3、實現代理
- (void)textFieldDidChange:(UITextField *)textField {

        if (self.phoneTextField.text.length == 11 && [self.passwordTextField isNotBlank]) {
             self.loginButton.enabled = YES;
        } else {
             self.loginButton.enabled = NO;
        }
}
複製程式碼

而使用 RAC 則如下

    @weakify(self);
    [[[RACSignal combineLatest:@[self.phoneTextField.rac_textSignal,
                                 self.passwordTextField.rac_textSignal]] map:^id _Nullable(RACTuple * _Nullable value) {
        
        RACTupleUnpack(NSString *phone, NSString *password) = value;
        return @([password isNotBlank] && phone.length == 11);
        
    }] subscribeNext:^(NSNumber *x) {
        @strongify(self);
        if (x.boolValue) {
            self.loginButton.enabled = YES;
        } else{
            self.loginButton.enabled = NO;
        }
    }];
複製程式碼

我們將 self.phoneTextField.rac_textSignalself.passwordTextField.rac_textSignal 這兩個訊號合併成一個訊號並且監聽,實現一定的邏輯,簡單明瞭。當然, RAC 的好處遠遠不止這些,這裡只是冰山一角,有興趣的可以去自己用一用這個庫,體驗更多的功能,這裡也就不多贅述了。

關於元件化

元件化這個概念相信大家都聽說過,使用元件化的好處就是使我們專案更好的解耦,降低各個分層之間的耦合度,使專案始終保持著 高聚合,低耦合 的特點。舉個簡單的例子,在 iOS 中頁面之間的跳轉,兩個開發人員負責開發兩個頁面,小 A 負責開發的 AViewController 已經開發完畢,然後需要點選按鈕跳到小 B 負責的 BViewController,並且需要傳一個值,如下

//1、匯入BViewController
#import "BViewController"
//2、跳轉
BViewController *bViewController = [[BViewController alloc]init];
bViewController.uid = @"123";
[self.navigationController pushViewController:bViewController animated:YES];
複製程式碼

這時候小 A 已經準備去寫其他業務了,但是一問才發現小 B 並沒有開始寫 BViewController,還需要一段時間才能寫,那麼小 A 就鬱悶了,要麼就等著小 B 寫完我再去做其他的,要麼就先註釋我這段程式碼,等到小 B 寫完我再解註釋。造成這種情況的原因就是因為兩個頁面之間緊緊地耦合在一起了,在開發人員少或者獨立開發的情況下我們經常使用這種方式進行頁面間的跳轉和傳值,頁面基本都是一個人負責,所以感覺不到問題,試想一下在幾十人開發的工作組中,劃分很細的情況下,你自己的脫節是不是給別人帶去了不必要的麻煩。我相信這是所有人都不想發生的,那麼我們就需要對頁面進行元件化解耦,這裡我所使用的元件化方案是 target-action 方式,使用的是 Casa TaloyumCTMediator,其主要的思想就是通過一箇中間者來提供服務,通過 runtime來呼叫元件服務,比如以前的依賴關係如下

談談關於 iOS 的架構以及應用
那麼使用 CTMediator 實現元件化以後,各元件之間的依賴關係變成下圖
談談關於 iOS 的架構以及應用
這樣各模組之間就實現瞭解耦,模組之間的通訊就全部通過中間層來進行。我們回過頭來再看之前的小 A 和小 B,如果使用這種方式,那麼小 A 的跳轉程式碼應該如下

//1、匯入Mediator
#import "CTMediator+BViewControllerActions.h"
//2、跳轉
UIViewController *viewController = [[CTMediator sharedInstance] gc_bViewController:@{@"uid": @"123"}];
[self.navigationController pushViewController:viewController animated:YES complete:nil];
複製程式碼

這樣小 A 就不用管小 B 是不是寫完沒,也不需要匯入小 B 的頁面,就可以跳轉到小 B 的頁面,實現了頁面間的解耦。能達到這一目的的功臣就是我們的中間者,我們來看看它做了什麼,我們還是以我們登入頁面為例,我們從登陸跳轉到註冊頁面的程式碼如下

//1、匯入Mediator
#import "CTMediator+RegistViewControllerActions.h"
//2、跳轉
UIViewController *viewController = [[CTMediator sharedInstance] gc_registViewController];
[self.navigationController pushViewController:viewController animated:YES complete:nil];
複製程式碼

其中 CTMediator+RegistViewControllerActions.h 中的程式碼如下

//
//  CTMediator+RegistViewControllerActions.m
//  GCUser
//
//  Created by HenryCheng on 2019/4/15.
//  Copyright © 2019 HenryCheng. All rights reserved.
//
#import "CTMediator+RegistViewControllerActions.h"
NSString *const gc_targetRegistVC = @"RegistViewController";
NSString *const gc_actionRegistVC = @"registViewController";
@implementation CTMediator (RegistViewControllerActions)
- (UIViewController *)gc_registViewController {
        UIViewController *viewController = [self performTarget:gc_targetRegistVC
                                                        action:gc_actionRegistVC
                                                        params:@{@"title": @"註冊"}
                                             shouldCacheTarget:NO
                                            ];
        if ([viewController isKindOfClass:[UIViewController class]]) {
            return viewController;
        } else {
            return [[UIViewController alloc] init];
        }
}
@end
複製程式碼

其中重要的就是 performTarget:action:params:shouldCacheTarget: 這個方法,內部的實現方式如下

- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget {
        NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];
        // generate target
        NSString *targetClassString = nil;
        if (swiftModuleName.length > 0) {
            targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
        } else {
            targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
        }
        NSObject *target = self.cachedTarget[targetClassString];
        if (target == nil) {
            Class targetClass = NSClassFromString(targetClassString);
            target = [[targetClass alloc] init];
        }
        // generate action
        NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
        SEL action = NSSelectorFromString(actionString);
        
        if (target == nil) {
            // 這裡是處理無響應請求的地方之一,這個demo做得比較簡單,如果沒有可以響應的target,就直接return了。實際開發過程中是可以事先給一個固定的target專門用於在這個時候頂上,然後處理這種請求的
            [self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
            return nil;
        }
        
        if (shouldCacheTarget) {
            self.cachedTarget[targetClassString] = target;
        }
    
        if ([target respondsToSelector:action]) {
            return [self safePerformAction:action target:target params:params];
        } else {
            // 這裡是處理無響應請求的地方,如果無響應,則嘗試呼叫對應target的notFound方法統一處理
            SEL action = NSSelectorFromString(@"notFound:");
            if ([target respondsToSelector:action]) {
                return [self safePerformAction:action target:target params:params];
            } else {
                // 這裡也是處理無響應請求的地方,在notFound都沒有的時候,這個demo是直接return了。實際開發過程中,可以用前面提到的固定的target頂上的。
                [self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
                [self.cachedTarget removeObjectForKey:targetClassString];
                return nil;
            }
        }
}
複製程式碼

可以看到如果有響應則呼叫 safePerformAction:target: params: 這個方法,如下

- (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
}
複製程式碼

通過這兩個方法我們就可以看到整個 CTMediator 實現的思路了,為什麼 AViewController 不引用 BViewController 還能向其進行跳轉傳值,原來都是由於 runtime 在中間起作用。 當然,雖然中間者這個方案能很好地實現各頁面之間的解耦,但是也有它的缺點。我們可以看到我們在 CTMediator+RegistViewControllerActions.h 中定義的 gc_targetRegistVCgc_actionRegistVC 這兩個常量,分別對應 ‘target’ 和 ‘action’,這裡面需要注意的是一定要細心,如果這兒寫錯,會引發未知的錯誤,但是編譯器並不會提示,對應的 Target_...一定要和這裡的 target 一致,否則就會引發錯誤。這種方案的實施對開發人員的細心程度是有很大要求的,因為如果有錯誤,在編譯中無法發現的。 元件化的方案的實施還有很多其他的方案,比如 url-blockprotocol-class方式,有興趣的可以看看蘑菇街的 MGJRouter,還有就是阿里的 BeeHive ,它是基於 Spring 的 Service 理念,使用 Protocol 的方式進行模組間的解耦。

關於 AOP

先看一個案例,小 C 最近愁眉苦臉,你發現了他狀態不對勁,於是就發生了下面的對話

你:“小 C,你這是怎麼啦,是不是工作上有什麼不順心的?”

小 C:“是啊,最近接到一個需求,讓我很頭疼!”

你:“接到需求不是很正常,做就是了啊!”

小 C:“你不知道,這個需求是統計每個頁面的瀏覽情況,就是使用者到了這個頁面我就要統計一下,
運營產品他們要看 PV,於是我就在基類裡面的 `viewDidLoad` 方法加了一下,這樣很簡單就解決了”

小 C:“可是他們又說還要我做每個頁面按鈕的點選統計,你說這 APP 幾百個頁面,這麼多按鈕,我怎麼加啊,
就算我加了,我的程式碼也會因為這些與業務無關的程式碼而變得混亂,萬一哪天不統計再讓我刪了,那我不是要命了啊!愁死我了!”

你:“那你這使用 AOP 就可以了啊”

小 C :“A...OP???”
複製程式碼

AOP(Aspect-oriented programming),面向切面程式設計,是電腦科學中的一種程式設計思想,旨在將橫切關注點與業務主體進行進一步分離,以提高程式程式碼的模組化程度。在 iOS 中有一個應用非常多的輕量級的 AOP 庫 Aspects ,它允許你能在任何一個類和例項的方法中插入新的程式碼。看到這裡,你可能就已經知道小 C 的問題該如何解決了,下面是使用 Aspects 實現頁面統計的程式碼

//
//  GCViewControllerIntercepter.m
//  GCUser
//
//  Created by HenryCheng on 2019/4/25.
//  Copyright © 2019 HenryCheng. All rights reserved.
//

#import "GCViewControllerIntercepter.h"
#import <Aspects/Aspects.h>

@implementation GCViewControllerIntercepter

+ (void)load {
    [GCViewControllerIntercepter sharedInstance];
    
}

+ (GCViewControllerIntercepter *)sharedInstance {
    static GCViewControllerIntercepter *_sharedClient = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _sharedClient = [[GCViewControllerIntercepter alloc] init];
    });
    return _sharedClient;
}

- (instancetype)init {
    if (self == [super init]) {
    
        [UIViewController aspect_hookSelector:@selector(viewDidLoad)
                           withOptions:AspectPositionAfter
                            usingBlock:^(id<AspectInfo> aspectInfo, UITouch *touch, UIEvent *event) {
                                
                                if ([aspectInfo.instance isKindOfClass:[UIViewController class]]) {
                                    
//                                    頁面統計的程式碼
                                }
                            } error:NULL];
        
        [UIControl aspect_hookSelector:@selector(beginTrackingWithTouch:withEvent:)
                           withOptions:AspectPositionAfter
                            usingBlock:^(id<AspectInfo> aspectInfo, UITouch *touch, UIEvent *event) {
                                
                                if ([aspectInfo.instance isKindOfClass:[UIButton class]]) {
//                                    按鈕統計的程式碼
                                    
                                }
                            } error:NULL];
        
        
    }
    return self;
}

@end
複製程式碼

我們可以看到,通過新建 GCViewControllerIntercepter 這個類就實現了頁面的統計和按鈕點選統計功能,你只需要實現就行,連匯入都不用,如果哪天你不需要這些統計的程式碼了,你直接從專案中移除這個類就可以了。是不是很簡單!這就是 AOP 的一個使用例項,通過 + (void)load 這個方法(+ load 作為 Objective-C 中的一個方法,與其它方法有很大的不同。它只是一個在整個檔案被載入到執行時,在 main 函式呼叫之前被 ObjC 執行時呼叫的鉤子方法),實現了 GCViewControllerIntercepter 這個類被呼叫,然後通過 Aspects 實現對 UIViewController 和 UIControl 的 hook。這樣在每個頁面被載入、每個按鈕被點選之前這邊就可以捕捉到。 還有就是有人提到過去基類,也就是拋棄厚重的 base ,直接使用 AOP ,這樣的話比如我想寫個新 demo 就不用引入各種父類了,直接 hook 拿來用就好了。這種方法個人覺得沒有到大工程的時候還是用繼承來實現比較好。如果工程量比較大便於各個開發人員除錯,可以使用這種方法。 當然 AOP 的作用也不僅如此,這裡就說這麼一個我們常用的 hook 的例子,有興趣可以下去好好了解下。

1、AspectOptions 有四個值,分別是 AspectPositionAfterAspectPositionInsteadAspectPositionBeforeAspectOptionAutomaticRemoval,這樣你可以決定你 hook 的位置

2、對於 + (void)load 還有 + (void)initialize 這兩個方法不是太瞭解的童鞋可以看看大左 Draveness你真的瞭解 load 方法麼?懶惰的 initialize 方法 這兩篇文章,瞭解這兩個方法相信對你會很有幫助

實際專案中的應用

瞭解了上面的內容,接下來我們看看在實際專案中的應用

  • 專案的目錄結構

    談談關於 iOS 的架構以及應用
    重構的專案結構如上圖,相信大家一看名稱就大概知道每個資料夾是做什麼的,由於 ModelViewViewControllerViewModel 這幾個類聯絡比較緊密,所以建議這幾個類的專案結構保持一致,如下圖
    談談關於 iOS 的架構以及應用
    這樣目錄一目瞭然,比如你想找一個登入相關的東西,那麼你就知道可以在各大目錄下的 Login 模組裡面去尋找。而且建議目錄不要過深,一般三層就夠了,過深的話查詢起來比較麻煩。

  • Category 的使用

    可能大家已經看到了,我的專案目錄裡面有一項是 AppDelegate+Config 這一項,這其實就是 AppDelegate 的一個 Category 。在 iOS 開發中 Category 隨處可見,如何應用那就是看自己的需求情況了,這裡我用 AppDelegate+Config 這個類來處理 AppDelegate 裡面的一些配置,減少 AppDelegate 的程式碼,讓專案更加清晰,使用了以後我們可以看到 AppDelegate 目錄的程式碼片段

     #import "AppDelegate.h"
     #import "AppDelegate+Config.h"
     #import "GCPushManager.h"
     
     @interface AppDelegate ()
     
     @end
     
     @implementation AppDelegate
     - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
     
         [self configTabbar];
         [self registWeChat];
         [self configNetWork];
         [self configJPushWithLaunchOptions:launchOptions];
         [self configKeyboard];
         [self configBaiduMobStat];
         [self configShareSDKWithLaunchOptions:launchOptions];
         return YES;
     }
     // between iOS 4.2 - iOS 9.0
     - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(nullable NSString *)sourceApplication annotation:(id)annotation {
         [self handleOpenURL:url];
          return NO;
     }
     // after iOS 9.0
     - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
          [self handleOpenURL:url];
          return NO;
     }
    複製程式碼

    這樣程式碼看起來很清晰,相信大家都有過一開啟 AppDelegate 這個類看到一大堆程式碼,東找西找很不規範的經歷。由於是專案重構初期,AppDelegateAppDelegate+Config 使用的比較多,暫時先放在這裡,後期再將其移動到合適的位置。

  • CocoaPods 的使用

    相信這個東西大家都用過,為什麼要強調一下 CocoaPods 的使用,因為在我整理之前專案時發現,有的地方(比如微信支付、支付寶支付)就是直接將 lib 直接拖進工程,有的還需要各種配置,這樣如果升級或者移除的時候就很麻煩。使用 CocoaPods 管理的話那麼升級或者移除就很方便,所以建議還是能使用 CocoaPods 安裝的就直接使用其安裝,最好不要直接在專案中新增第三方。 還有一種情況就是有時候第三方滿足不了我們的需求,需要修改一下,所以有些就不整合在 CocoaPods 裡面了(萬一一不小心 update 以後修改的內容被覆蓋)。這裡我想說的是,對於這種情況你仍然可以使用 CocoaPods,那麼怎麼解決需要修改程式碼的問題?沒錯,就是 Category !

  • MVVM的運用

    具體專案的實現我們還是以登入為例,在 ViewModel 中

     - (void)initialize {
          [super initialize];
          RAC(self, isLoginEnable) = [[RACSignal combineLatest:@[
                                                                 RACObserve(self, phone),
                                                                 RACObserve(self, password)
                                                                 ]] map:^id _Nullable(RACTuple * _Nullable value) {
                                          RACTupleUnpack(NSString *phone, NSString *password) = value;
                                          return @([phone isNotBlank] && [password isNotBlank] && phone.length == 11); }];
          
          RAC(self.loginRequest, params) = [[RACSignal combineLatest:@[
                                                          RACObserve(self, phone),
                                                          RACObserve(self, password)
                                                          ]] map:^id _Nullable(RACTuple * _Nullable value) {
                                 
                                   RACTupleUnpack(NSString *phone, NSString *password) = value;
                                       return @{@"phone": GC_NO_BLANK(phone),
                                                @"pwd": GC_NO_BLANK(password)
                                                }; }];
     }
     - (RACCommand *)loginCommand {
          if (!_loginCommand) {
              @weakify(self);
              _loginCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal * _Nonnull(id  _Nullable input) {
                  @strongify(self);
                  return [self.loginRequest requestSignal] ;
              }];
          }
          return _loginCommand;
     }
    複製程式碼

    這裡我們做了網路的請求以及一些資料的繫結,在 ViewController 中

    - (void)gc_bindViewModel {
         [super gc_bindViewModel];
         
         RAC(self.viewModel, phone) = self.loginView.phoneTextField.rac_textSignal;
         RAC(self.viewModel, password) = self.loginView.passwordTextField.rac_textSignal;
         RAC(self.loginView.loginButton, enabled) = RACObserve(self.viewModel, isLoginEnable);
         
         @weakify(self);
         
         [[[self.loginView.loginButton rac_signalForControlEvents:UIControlEventTouchUpInside] throttle:0.25] subscribeNext:^(__kindof UIControl * _Nullable x) {
             @strongify(self);
             [self.viewModel.loginCommand execute:nil];
         }];
         
         [[self.viewModel.loginCommand.executing skip:1] subscribeNext:^(NSNumber * _Nullable x) {
             if (x.boolValue) {
                 [GCHUDManager show];
             } else {
                 [GCHUDManager dismiss];
             }
         }];
         // 登入命令監聽
         [self.viewModel.loginCommand.executionSignals.switchToLatest subscribeNext:^(NSDictionary *x) {
             @strongify(self);
             UserModel *userModel = [UserModel modelWithDictionary:x];
             [[GCCacheManager sharedManager] updateDataWithDictionary:x key:GCUserInfoStoreKey()];
             [GCPushManager gc_setAlias:x[@"phone"]];
             if (userModel.is_agree.intValue == 0) {
     //            未同意甘草協議
     
             } else if (userModel.is_agree.intValue == 1 && userModel.pwd_status.intValue == 0) {
     //            同意協議但是沒改過密碼
     
             } else {
     //            登入
             }
         } error:^(NSError * _Nullable error) {
             
         }];
    }
    複製程式碼

    可以看到 ViewController 將 View 和 ViewModel 進行了繫結,並且當登入按鈕點選的時候監測登入訊號的變化,根據其訊號執行的開始和結束來控制 HUD 的顯示和消失,然後再根據訊號的返回結果來處理相關的登入配置和跳轉(極光推送的登入、根據狀態執行跳轉邏輯等)。這裡網路的請求都是在 ViewModel 中進行的,ViewController 只負責處理ViewModel、View 和 Model 之間的關係。

  • DRY

    DRY(Don't repeat yourself),能封裝起來的類一定要封裝起來,到時候使用也簡單,千萬不要為了一時之快而各種 ctrl + cctrl + v,這樣會使你的程式碼混亂不堪,這其實也是專案臃腫的一個原因。在重構的過程中就封裝了很多的類,管理起來很方便

談談關於 iOS 的架構以及應用

一些感想

其實最開始的時候一直都有重構的想法,但是遲遲沒有動手。其中一個原因就是不知道該如何動手,不知道該使用什麼工具,該使用哪種方案。等到真正開始的時候發現其實沒有想象中的那麼難,所以當你有想法的時候你就去做,在做的過程中你可以慢慢體會。 在重構之前,我又重新讀了一下程式碼規範,也就是 禪與 Objective-C 程式設計藝術 這本書,並在重構的過程中嚴格執行,比如 loginButton 就絕不會寫成 loginBtn,相信我,按著規範來,你會體會到其中的意義的。 在做一個 APP 之前,在我們新建工程的時候,就應該已經確定你的架構模式,並且在以後的業務處理中,嚴格的按著這種設計模式執行下去。如果在前面需求量不多的時候你還能按著最初的設計模式執行下去,在業務突然增多的時候,為了偷懶省事,直接各種程式碼混亂的糅合在一起,各種 ctrl + cctrl + v,導致架構的混亂引起蝴蝶效應,那麼這個架構在後期如果再想重新規範起來將會是個費時費力的過程。所以,在最初設計的時候我們就應該確定架構方案,以及嚴格的執行下去。 還有就是平時的一些技術積累以及知識儲存。知其然知其所以然,研究技術背後的底層原理,會對你有很大的幫助。比如說我要說來說說 ViewController 的生命週期,可能大家都會隨口說出 viewDidLoadviewWillAppear 等,我要問說說 View 的生命週期,可能就會有少數人茫然了。這些都是很基本的東西,可能你平時用不到,但是還是需要你去了解他,注意細節。很多人可能會經常有這樣的困惑,比如我想寫一個圖片瀏覽器,但是我不知道該如何寫?寫完了效能如何?別人是怎麼寫的?這個就是需要平時的積累了,比如關於 UIText 相關的的你就得想到 YYText,資料儲存方面的你不僅要知道老的 fmdb ,微信開源的 wcdb 有沒有去了解下呢?比如我就平時沒事喜歡在 GitHub 上看一些 star 比較高的開源庫,看看別人是怎麼實現的,想想在我的專案中怎麼使用。舉個例子,最近阿里開源的 協程 框架 coobjc ,就在專案中使用,用來判斷使用者是否登入

- (void)judgeLoginBlock:(void(^)(GCLoginStatus status))block {

    co_launch(^{
        NSDictionary *dic = await([self co_loginRequest]);
        if (co_getError()) {
            block(GCLoginStatusError);
        } else if (dic) {
            if ([dic[@"status"] intValue] == 1) {
                block(GCLoginStatusLogin);
            } else if ([dic[@"status"] intValue] == -99) {
                block(GCLoginStatusUnLogin);
            } else {
                block(GCLoginStatusError);
            }
        } else {
            block(GCLoginStatusError);
        }
    });
}
複製程式碼

一眼看去邏輯就很簡單明瞭,比 Block 巢狀 Block 這種方式優雅的多。 現在只是重構的開始,現在已經完成的登入的重構就 LoginViewController 而言,與之前相比就已經有很大的改變了(之前將近 800 行程式碼,重構後只有 200 行),可能總體上各個模組程式碼加起來都差不多,但是為 ViewController 減負後更加清晰明瞭了。後面重構完成後會出一個程式碼量、包大小、效能等的對比,到時候再與大家分享!

Reference

1、淺談 MVC、MVP 和 MVVM 架構模式

2、iOS應用架構談 view層的組織和呼叫方案

3、iOS 如何實現Aspect Oriented Programming

4、CTMediator

5、BeeHive

6、objc-zen-book

7、coobjc

相關文章