普通视图

发现新文章,点击刷新页面。
昨天以前Casa Taloyum

在现有工程中实施基于CTMediator的组件化方案

作者 Casa Taloyum
2016年12月10日 00:00




前述



国内业界大家对组件化的讨论从今年年初开始到年尾,不外乎两个方案:URL/protocol注册调度,runtime调度。


我之前批评过URL注册调度是错误的组件化实施方案,在所有的基于URL注册调度的方案中,存在两个普遍问题:


  1. 命名域渗透
  2. 因注册是不必要的,而带来同样不必要的注册列表维护成本


其它各家的基于URL注册的不同方案在这两个普遍问题上还有各种各样的其他问题,例如FRDIntent库中的FRDIntent对象其本质是鸡肋对象、原属于响应者的业务被渗透到调用者的业务中、组件化实施方案的过程中会产生对原有代码的侵入式修改等问题。


另外,我也发现还是有人在都没有理解清楚的前提下就做出了自己的解读,流毒甚广。我之前写过关于CTMediator比较理论的描述,也有Demo,但惟独没有写实践方面的描述。我本来以为Demo就足够了,可现在看来还是要给一篇实践的文章的。


在更早之前,卓同学的swift老司机群里也有人提出因为自己并没有理解透彻CTMediator方案,所以不敢贸然直接在项目中应用。所以这篇文章的另一个目的也是希望能够让大家明白,基于CTMediator的组件化方案实施其实非常简单,而且也是有章法可循的。这篇文章可能会去讨论一些理论的东西,但主要还会是以实践为主。争取做到能够让大家看完文章之后就可以直接在自己的项目中顺利实施组件化。


最后,我希望这篇文章能够终结业界持续近一年的关于组件化方案的无谓讨论和错误讨论。




准备工作



我在github上开了一个orgnization,里面有一个主工程:MainProject,我们要针对这个工程来做组件化。组件化实施完毕之后的主工程就是ModulizedMainProject了。抽出来的独立Pod、私有Pod源也都会放在这个orgnization中去。


在一个项目实施组件化方案之前,我们需要做一个准备工作,建立自己的私有Pod源和快手工具脚本的配置:


  1. 先去开一个repo,这个repo就是我们私有Pod源仓库
  2. pod repo add [私有Pod源仓库名字] [私有Pod源的repo地址]
  3. 创立一个文件夹,例如Project。把我们的主工程文件夹放到Project下:~/Project/MainProject
  4. 在~/Project下clone快速配置私有源的脚本repo:git clone git@github.com:casatwy/ConfigPrivatePod.git
  5. 将ConfigPrivatePod的template文件夹下Podfile中source 'https://github.com/ModulizationDemo/PrivatePods.git'改成第一步里面你自己的私有Pod源仓库的repo地址
  6. 将ConfigPrivatePod的template文件夹下upload.sh中PrivatePods改成第二步里面你自己的私有Pod源仓库的名字



最后你的文件目录结构应该是这样:


Project
├── ConfigPrivatePod
└── MainProject



到此为止,准备工作就做好了。




实施组件化方案第一步:创建私有Pod工程和Category工程



MainProject是一个非常简单的应用,一共就三个页面。首页push了AViewController,AViewController里又push了BViewController。我们可以理解成这个工程由三个业务组成:首页、A业务、B业务。


我们这一次组件化的实施目标就是把A业务组件化出来,首页和B业务都还放在主工程。


因为在实际情况中,组件化是需要循序渐进地实施的。尤其是一些已经比较成熟的项目,业务会非常多,一时半会儿是不可能完全组件化的。CTMediator方案在实施过程中,对主工程业务的影响程度极小,而且是能够支持循序渐进地改造方式的。这个我会在文章结尾做总结的时候提到。


既然要把A业务抽出来作为组件,那么我们需要为此做两个私有Pod:A业务Pod(以后简称A Pod)、方便其他人调用A业务的CTMediator category的Pod(以后简称A_Category Pod)。这里多解释一句:A_Category Pod本质上只是一个方便方法,它对A Pod不存在任何依赖。


我们先创建A Pod




  1. 新建Xcode工程,命名为A,放到Projects下
  2. 新建Repo,命名也为A,新建好了之后网页不要关掉


此时你的文件目录结构应该是这样:



Project
├── ConfigPrivatePod
├── MainProject
└── A



然后cd到ConfigPrivatePod下,执行./config.sh脚本来配置A这个私有Pod。脚本会问你要一些信息,Project Name就是A,要跟你的A工程的目录名一致。HTTPS RepoSSH Repo网页上都有,Home Page URL就填你A Repo网页的URL就好了。


这个脚本是我写来方便配置私有库的脚本,pod lib create也可以用,但是它会直接从github上拉一个完整的模版工程下来,只是国内访问github其实会比较慢,会影响效率。而且这个配置工作其实也不复杂,我就索性自己写了个脚本。


这个脚本要求私有Pod的文件目录要跟脚本所在目录平级,也会在XCode工程的代码目录下新建一个跟项目同名的目录。放在这个目录下的代码就会随着Pod的发版而发出去,这个目录以外的代码就不会跟随Pod的版本发布而发布,这样子写用于测试的代码就比较方便。


然后我们在主工程中,把属于A业务的代码拎出来,放到新建好的A工程的A文件夹里去,然后拖放到A工程中。原来主工程里面A业务的代码直接删掉,此时主工程和A工程编译不过都是正常的,我们会在第二步中解决主工程的编译问题,第三步中解决A工程的编译问题。


此时你的主工程应该就没有A业务的代码了,然后你的A工程应该是这样:



A
├── A
|   ├── A
|   │   ├── AViewController.h
|   │   └── AViewController.m
|   ├── AppDelegate.h
|   ├── AppDelegate.m
|   ├── ViewController.h
|   ├── ViewController.m
|   └── main.m
└── A.xcodeproj




我们再创建A_Category Pod



同样的,我们再创建A_Category,因为它也是个私有Pod,所以也照样子跑一下config.sh脚本去配置一下就好了。最后你的目录结构应该是这样的:


Project
├── A
   ├── A
      ├── A
      ├── AppDelegate.h
      ├── AppDelegate.m
      ├── Assets.xcassets
      ├── Info.plist
      ├── ViewController.h
      ├── ViewController.m
      └── main.m
   ├── A.podspec
   ├── A.xcodeproj
   ├── FILE_LICENSE
   ├── Podfile
   ├── readme.md
   └── upload.sh
├── A_Category
   ├── A_Category
      ├── A_Category
      ├── AppDelegate.h
      ├── AppDelegate.m
      ├── Info.plist
      ├── ViewController.h
      ├── ViewController.m
      └── main.m
   ├── A_Category.podspec
   ├── A_Category.xcodeproj
   ├── FILE_LICENSE
   ├── Podfile
   ├── readme.md
   └── upload.sh
├── ConfigPrivatePod
   ├── config.sh
   └── templates
└── MainProject
    ├── FILE_LICENSE
    ├── MainProject
    ├── MainProject.xcodeproj
    ├── MainProject.xcworkspace
    ├── Podfile
    ├── Podfile.lock
    ├── Pods
    └── readme.md


然后去A_Category下,在Podfile中添加一行pod "CTMediator",在podspec文件的后面添加s.dependency "CTMediator",然后执行pod install --verbose


接下来打开A_Category.xcworkspace,把脚本生成的名为A_Category的空目录拖放到Xcode对应的位置下,然后在这里新建基于CTMediator的Category:CTMediator+A。最后你的A_Category工程应该是这样的:



A_Category
├── A_Category
|   ├── A_Category
|   │   ├── CTMediator+A.h
|   │   └── CTMediator+A.m
|   ├── AppDelegate.h
|   ├── AppDelegate.m
|   ├── ViewController.h
|   └── ViewController.m
└── A_Category.xcodeproj

到这里为止,A工程和A_Category工程就准备好了。




实施组件化方案第二步:在主工程中引入A_Category工程,并让主工程编译通过



去主工程的Podfile下添加pod "A_Category", :path => "../A_Category"来本地引用A_Category。


然后编译一下,说找不到AViewController的头文件。此时我们把头文件引用改成#import <A_Category/CTMediator+A.h>


然后继续编译,说找不到AViewController这个类型。看一下这里是使用了AViewController的地方,于是我们在Development Pods下找到CTMediator+A.h,在里面添加一个方法:



- (UIViewController *)A_aViewController;



再去CTMediator+A.m中,补上这个方法的实现,把主工程中调用的语句作为注释放进去,将来写Target-Action要用:



- (UIViewController *)A_aViewController
{
    /*
        AViewController *viewController = [[AViewController alloc] init];
     */
    return [self performTarget:@"A" action:@"viewController" params:nil shouldCacheTarget:NO];
}



补充说明一下,performTarget:@"A"中给到的@"A"其实是Target对象的名字。一般来说,一个业务Pod只需要有一个Target就够了,但一个Target下可以有很多个Action。Action的名字也是可以随意命名的,只要到时候Target对象中能够给到对应的Action就可以了。


关于Target-Action我们会在第三步中去实现,现在不实现Target-Action是不影响主工程编译的。


category里面这么写就已经结束了,后面的实施过程中就不会再改动到它了。


然后我们把主工程调用AViewController的地方改为基于CTMediator Category的实现:



    UIViewController *viewController = [[CTMediator sharedInstance] A_aViewController];
    [self.navigationController pushViewController:viewController animated:YES];



再编译一下,编译通过。

到此为止主工程就改完了,现在跑主工程点击这个按钮跳不到A页面是正常的,因为我们还没有在A工程中实现Target-Action。


而且此时主工程中关于A业务的改动就全部结束了,后面的组件化实施过程中,就不会再有针对A业务线对主工程的改动了。




实施组件化方案第三步:添加Target-Action,并让A工程编译通过



此时我们关掉所有XCode窗口。然后打开两个工程:A_Category工程和A工程。


我们在A工程中创建一个文件夹:Targets,然后看到A_Category里面有performTarget:@"A",所以我们新建一个对象,叫做Target_A


然后又看到对应的Action是viewController,于是在Target_A中新建一个方法:Action_viewController。这个Target对象是这样的:



头文件
#import <UIKit/UIKit.h>

@interface Target_A : NSObject

- (UIViewController *)Action_viewController:(NSDictionary *)params;

@end

实现文件
#import "Target_A.h"
#import "AViewController.h"

@implementation Target_A

- (UIViewController *)Action_viewController:(NSDictionary *)params
{
    AViewController *viewController = [[AViewController alloc] init];
    return viewController;
}

@end



这里写实现文件的时候,对照着之前在A_Category里面的注释去写就可以了。


因为Target对象处于A的命名域中,所以Target对象中可以随意import A业务线中的任何头文件。


另外补充一点,Target对象的Action设计出来也不是仅仅用于返回ViewController实例的,它可以用来执行各种属于业务线本身的任务。例如上传文件,转码等等各种任务其实都可以作为一个Action来给外部调用,Action完成这些任务的时候,业务逻辑是可以写在Action方法里面的。


换个角度说就是:Action具备调度业务线提供的任何对象和方法来完成自己的任务的能力。它的本质就是对外业务的一层服务化封装。


现在我们这个Action要完成的任务只是实例化一个ViewController并返回出去而已,根据上面的描述,Action可以完成的任务其实可以更加复杂。


然后我们再继续编译A工程,发现找不到BViewController。由于我们这次组件化实施的目的仅仅是将A业务线抽出来,BViewController是属于B业务线的,所以我们没必要把B业务也从主工程里面抽出来。但为了能够让A工程编译通过,我们需要提供一个B_Category来使得A工程可以调度到B,同时也能够编译通过。


B_Category的创建步骤跟A_Category是一样的,不外乎就是这几步:新建Xcode工程、网页新建Repo、跑脚本配置Repo、添加Category代码。


B_Category添加好后,我们同样在A工程的Podfile中本地指过去,然后跟在主工程的时候一样。


所以B_Category是这样的:



头文件
#import <CTMediator/CTMediator.h>
#import <UIKit/UIKit.h>

@interface CTMediator (B)

- (UIViewController *)B_viewControllerWithContentText:(NSString *)contentText;

@end

实现文件
#import "CTMediator+B.h"

@implementation CTMediator (B)

- (UIViewController *)B_viewControllerWithContentText:(NSString *)contentText
{
    /*
        BViewController *viewController = [[BViewController alloc] initWithContentText:@"hello, world!"];
     */
    NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
    params[@"contentText"] = contentText;
    return [self performTarget:@"B" action:@"viewController" params:params shouldCacheTarget:NO];
}

@end



然后我们对应地在A工程中修改头文件引用为#import <B_Category/CTMediator+B.h>,并且把调用的代码改为:



    UIViewController *viewController = [[CTMediator sharedInstance] B_viewControllerWithContentText:@"hello, world!"];
    [self.navigationController pushViewController:viewController animated:YES];



此时再编译一下,编译通过了。注意哦,这里A业务线跟B业务线就已经完全解耦了,跟主工程就也已经完全解耦了。




实施组件化方案最后一步:收尾工作、组件发版



此时还有一个收尾工作是我们给B业务线创建了Category,但没有创建Target-Action。所以我们要去主工程创建一个B业务线的Target-Action。创建的时候其实完全不需要动到B业务线的代码,只需要新增Target_B对象即可:


Target_B头文件
#import <UIKit/UIKit.h>

@interface Target_B : NSObject

- (UIViewController *)Action_viewController:(NSDictionary *)params;

@end

Target_B实现文件
#import "Target_B.h"
#import "BViewController.h"

@implementation Target_B

- (UIViewController *)Action_viewController:(NSDictionary *)params
{
    NSString *contentText = params[@"contentText"];
    BViewController *viewController = [[BViewController alloc] initWithContentText:contentText];
    return viewController;
}

@end


这个Target对象在主工程内不存在任何侵入性,将来如果B要独立成一个组件的话,把这个Target对象带上就可以了。


收尾工作就到此结束,我们创建了三个私有Pod:A、A_Category、B_Category。


接下来我们要做的事情就是给这三个私有Pod发版,发版之前去podspec里面确认一下版本号和dependency。


Category的dependency是不需要填写对应的业务线的,它应该是只依赖一个CTMediator就可以了。其它业务线的dependency也是不需要依赖业务线的,只需要依赖业务线的Category。例如A业务线只需要依赖B_Category,而不需要依赖B业务线或主工程。


发版过程就是几行命令:



git add .
git commit -m "版本号"
git tag 版本号
git push origin master --tags
./upload.sh



命令行cd进入到对应的项目中,然后执行以上命令就可以了。


要注意的是,这里的版本号要和podspec文件中的s.version给到的版本号一致。upload.sh是配置私有Pod的脚本生成的,如果你这边没有upload.sh这个文件,说明这个私有Pod你还没用脚本配置过。


最后,所有的Pod发完版之后,我们再把Podfile里原来的本地引用改回正常引用,也就是把:path...那一段从Podfile里面去掉就好了,改动之后记得commit并push。


组件化实施就这么三步,到此结束。




总结



hard code


这个组件化方案的hard code仅存在于Target对象和Category方法中,影响面极小,并不会泄漏到主工程的业务代码中,也不会泄漏到业务线的业务代码中。


而且在实际组件化的实施中,也是依据category去做业务线的组件化的。所以先写category里的target名字,action名字,param参数,到后面在业务线组件中创建Target的时候,照着category里面已经写好的内容直接copy到Target对象中就肯定不会出错(仅Target对象,并不会牵扯到业务线本身原有的对象)。


如果要消除这一层hard code,那么势必就要引入一个第三方pod,然后target对象所在的业务线和category都要依赖这个pod。为了消除这种影响面极小的hard code,而且只要按照章法来就不会出错。为此引入一个新的依赖,其实是不划算的。



命名域问题


在这个实践中,响应者的命名域并没有泄漏到除了响应者以外的任何地方,这就带来一个好处,迁移非常方便。


比如我们的响应者是一个上传组件。这个上传组件如果要替换的话,只需要在它外面包一个Target-Action,就可以直接拿来用了。而且包Target-Action的过程中,不会产生任何侵入性的影响。


例如原来是你自己基于AFNetworking写的上传组件,现在用了七牛SDK上传,那么整个过程你只需要提供一个Target-Action封装一下七牛的上传操作即可。不需要改动七牛SDK的代码,也不需要改动调用方的代码。倘若是基于URL注册的调度,做这个事情就很蛋疼。



服务管理问题


由于Target对象处于响应者的命名域中,Target对象就可以对外提供除了页面实例以外的各种Action。


而且,由于其本质就是针对响应者对外业务逻辑的Action化封装(其实就是服务化封装),这就能够使得一个响应者对外提供了哪些Action(服务)Action(服务)的实现逻辑是什么得到了非常好的管理,能够大大降低将来工程的维护成本。然后Category解决了服务应该怎么调用的问题。


但在基于URL注册机制和Protocol共享机制的组件化方案中,由于服务散落在响应者各处,服务管理就显得十分困难。如果还是执念于这样的方案,大家只要拿上面提到的三个问题,对照着URL注册机制和Protocol共享机制的组件化方案比对一下,就能明白了。


另外,如果这种方案把所有的服务归拢到一个对象中来达到方便管理的目的的话,其本质就已经变成了Target-Action模式,Protocol共享机制其实就已经没有存在意义了。



高内聚


基于protocol共享机制的组件化方案导致响应者业务逻辑泄漏到了调用者业务逻辑中,并没有做到高内聚


如果这部分业务在其他地方也要使用,那么代码就要重新写一遍。虽然它可以提供一个业务高内聚的对象来符合这个protocol,但事实上这就又变成了Target-Action模式,protocol的存在意义就也没有了。



侵入性问题


正如你所见,CTMediator组件化方案的实施非常安全。因为它并不存在任何侵入性的代码修改。


对于响应者来说,什么代码都不用改,只需要包一层Target-Action即可。例如本例中的B业务线作为A业务的响应者时,不需要修改B业务的任何代码。


对于调用者来说,只需要把调用方式换成CTMediator调用即可,其改动也不涉及原有的业务逻辑,所以是十分安全的。


另外一个非侵入性的特征体现在,基于CTMediator的组件化方案是可以循序渐进地实施的。这个方案的实施并不要求所有业务线都要被独立出来成为组件,实施过程也并不会修改未组件化的业务的代码。


在独立A业务线的过程中如果涉及其它业务线(B业务线)的调用,就只需要给到Target对象即可,Target对象本身并不会对未组件化的业务线(B业务线)产生任何的修改。而且将来如果对应业务线需要被独立出去的时候,也仅需要把Target对象一起复制过去就可以了。


但在基于URL注册和protocol共享的组件化方案中,都必须要在未组件化的业务线中写入注册代码和protocol声明,并分配对应的URL和protocol到具体的业务对象上。这些其实都是不必要的,无端多出了额外维护成本。



注册问题


CTMediator没有任何注册逻辑的代码,避免了注册文件的维护和管理。Category给到的方法很明确地告知了调用者应该如何调用。


例如B_Category给到的- (UIViewController *)B_viewControllerWithContentText:(NSString *)contentText;方法。这能够让工程师一眼就能够明白使用方式,而不必抓瞎拿着URL再去翻文档。


这可以很大程度提高工作效率,同时降低维护成本。



实施组件化方案的时机


MVP阶段过后,越早实施越好。


这里说的MVP不是一种设计模式,而是最小价值产品的意思,它是产品演进的第一个阶段。


一般来说天使轮就是用于MVP验证的,在这个阶段产品闭环尚未确定,因此产品本身的逻辑就会各种变化。但是过了天使轮之后,产品闭环已经确定,此时就应当实施组件化,以应对A轮之后的产品拓张。


有的人说我现在项目很小,人也很少,所以没必要实施组件化。确实,把一个小项目组件化之后,跟之前相比并没有多大程度的改善,因为本来小项目就不复杂,改成组件化之后,也不会更简单。


但这其实是一种很短视的认知。


组件化对于一个小项目而言,真正发挥优势的地方是在未来的半年甚至一年之后。


因为趁着人少项目小,实施组件化的成本就也很小,三四天就可以实施完毕。于是等将来一年之后业务拓张到更大规模时,就不会束手束脚了。


但如果等到项目大了,人手多了再去实施组件化,那时候实施组件化的复杂度肯定比现在规模还很小的时候的复杂度要大得多,三四天肯定搞不定,而且实施过程还会非常艰辛。到那时你就后悔为什么当初没有早早实施组件化了。



Swift工程怎么办?


其实只要Target对象继承自NSObject就好了,然后带上@objc(className)。action的参数名永远只有一个,且名字需要固定为params,其它照旧。具体swift工程中target的写法参见A_swift

因为Target对象是游离于业务实现的,所以它去继承NSObject完全没有任何问题。完整的SwiftDemo在这里。








本文Demo




iOS应用架构谈 组件化方案

作者 Casa Taloyum
2016年3月13日 00:00




iOS应用架构谈 开篇
iOS应用架构谈 view层的组织和调用方案
iOS应用架构谈 网络层设计方案
iOS应用架构谈 本地持久化方案及动态部署
iOS应用架构谈 组件化方案




简述


前几天的一个晚上在infoQ的微信群里,来自蘑菇街的Limboy做了一个分享,讲了蘑菇街的组件化之路。我不认为这条组件化之路蘑菇街走对了。分享后我私聊了Limboy,Limboy似乎也明白了问题所在,我答应他我会把我的方案写成文章,于是这篇文章就出来了。


另外,按道理说组件化方案也属于iOS应用架构谈的一部分,但是当初构思架构谈时,我没打算写组件化方案,因为我忘了还有这回事儿。。。后来写到view的时候才想起来,所以在view的那篇文章最后补了一点内容。而且觉得这个组件化方案太简单,包括实现组件化方案的组件也很简单,代码算上注释也才100行,我就偷懒放过了,毕竟写一篇文章好累的啊。


本文的组件化方案demo在这里https://github.com/casatwy/CTMediator 拉下来后记得pod install 拉下来后记得pod install 拉下来后记得pod install,这个Demo对业务敏感的边界情况处理比较简单,这需要根据不同App的特性和不同产品的需求才能做,所以只是为了说明组件化架构用的。如果要应用在实际场景中的话,可以根据代码里给出的注释稍加修改,就能用了。




蘑菇街的原文地址在这里:《蘑菇街 App 的组件化之路》,没有耐心看完原文的朋友,我在这里简要介绍一下蘑菇街的组件化是怎么做的:


  1. App启动时实例化各组件模块,然后这些组件向ModuleManager注册Url,有些时候不需要实例化,使用class注册。
  2. 当组件A需要调用组件B时,向ModuleManager传递URL,参数跟随URL以GET方式传递,类似openURL。然后由ModuleManager负责调度组件B,最后完成任务。


这里的两步中,每一步都存在问题。


第一步的问题在于,在组件化的过程中,注册URL并不是充分必要条件,组件是不需要向组件管理器注册Url的。而且注册了Url之后,会造成不必要的内存常驻,如果只是注册Class,内存常驻量就小一点,如果是注册实例,内存常驻量就大了。至于蘑菇街注册的是Class还是实例,Limboy分享时没有说,文章里我也没看出来,也有可能是我看漏了。不过这还并不能算是致命错误,只能算是小缺陷。


真正的致命错误在第二步。在iOS领域里,一定是组件化的中间件为openUrl提供服务,而不是openUrl方式为组件化提供服务。


什么意思呢?


也就是说,一个App的组件化方案一定不是建立在URL上的,openURL的跨App调用是可以建立在组件化方案上的。当然,如果App还没有组件化,openURL方式也是可以建立的,就是丑陋一点而已。



为什么这么说?


因为组件化方案的实施过程中,需要处理的问题的复杂度,以及拆解、调度业务的过程的复杂度比较大,单纯以openURL的方式是无法胜任让一个App去实施组件化架构的。如果在给App实施组件化方案的过程中是基于openURL的方案的话,有一个致命缺陷:非常规对象无法参与本地组件间调度。关于非常规对象我会在详细讲解组件化方案时有一个辨析。


实际App场景下,如果本地组件间采用GET方式的URL调用,就会产生两个问题:


  • 根本无法表达非常规对象


比如你要调用一个图片编辑模块,不能传递UIImage到对应的模块上去的话,这是一个很悲催的事情。 当然,这可以通过给方法新开一个参数,然后传递过去来解决。比如原来是:


[a openUrl:"http://casa.com/detail?id=123&type=0"];


同时就也要提供这样的方法:


[a openUrl:"http://casa.com/detail" params:@{
    @"id":"123",
    @"type":"0",
    @"image":[UIImage imageNamed:@"test"]
}]


如果不像上面这么做,复杂参数和非常规参数就无法传递。如果这么做了,那么事实上这就是拆分远程调用和本地调用的入口了,这就变成了我文章中提倡的做法,也是蘑菇街方案没有做到的地方。


另外,在本地调用中使用URL的方式其实是不必要的,如果业务工程师在本地间调度时需要给出URL,那么就不可避免要提供params,在调用时要提供哪些params是业务工程师很容易懵逼的地方。。。在文章下半部分给出的demo代码样例已经说明了业务工程师在本地间调用时,是不需要知道URL的,而且demo代码样例也阐释了如何解决业务工程师遇到传params容易懵逼的问题。




  • URL注册对于实施组件化方案是完全不必要的,且通过URL注册的方式形成的组件化方案,拓展性和可维护性都会被打折


注册URL的目的其实是一个服务发现的过程,在iOS领域中,服务发现的方式是不需要通过主动注册的,使用runtime就可以了。另外,注册部分的代码的维护是一个相对麻烦的事情,每一次支持新调用时,都要去维护一次注册列表。如果有调用被弃用了,是经常会忘记删项目的。runtime由于不存在注册过程,那就也不会产生维护的操作,维护成本就降低了。

由于通过runtime做到了服务的自动发现,拓展调用接口的任务就仅在于各自的模块,任何一次新接口添加,新业务添加,都不必去主工程做操作,十分透明。




小总结



蘑菇街采用了openURL的方式来进行App的组件化是一个错误的做法,使用注册的方式发现服务是一个不必要的做法。而且这方案还有其它问题,随着下文对组件化方案介绍的展开,相信各位自然心里有数。




正确的组件化方案


先来看一下方案的架构图


             --------------------------------------
             | [CTMediator sharedInstance]        |
             |                                    |
             |                openUrl:       <<<<<<<<<  (AppDelegate)  <<<<  Call From Other App With URL
             |                                    |
             |                   |                |
             |                   |                |
             |                   |/               |
             |                                    |
             |                parseUrl            |
             |                                    |
             |                   |                |
             |                   |                |
.................................|...............................
             |                   |                |
             |                   |                |
             |                   |/               |
             |                                    |
             |  performTarget:action:params: <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<  Call From Native Module
             |                                    |
             |                   |                |
             |                   |                |
             |                   |                |
             |                   |/               |
             |                                    |
             |             -------------          |
             |             |           |          |
             |             |  runtime  |          |
             |             |           |          |
             |             -------------          |
             |               .       .            |
             ---------------.---------.------------
                           .           .
                          .             .
                         .               .
                        .                 .
                       .                   .
                      .                     .
                     .                       .
                    .                         .
-------------------.-----------      ----------.---------------------
|                 .           |      |          .                   |
|                .            |      |           .                  |
|               .             |      |            .                 |
|              .              |      |             .                |
|                             |      |                              |
|           Target            |      |           Target             |
|                             |      |                              |
|         /   |   \           |      |         /   |   \            |
|        /    |    \          |      |        /    |    \           |
|                             |      |                              |
|   Action Action Action ...  |      |   Action Action Action ...   |
|                             |      |                              |
|                             |      |                              |
|                             |      |                              |
|Business A                   |      | Business B                   |
-------------------------------      --------------------------------



这幅图是组件化方案的一个简化版架构描述,主要是基于Mediator模式和Target-Action模式,中间采用了runtime来完成调用。这套组件化方案将远程应用调用和本地应用调用做了拆分,而且是由本地应用调用为远程应用调用提供服务,与蘑菇街方案正好相反。




调用方式



先说本地应用调用,本地组件A在某处调用[[CTMediator sharedInstance] performTarget:targetName action:actionName params:@{...}]CTMediator发起跨组件调用,CTMediator根据获得的target和action信息,通过objective-C的runtime转化生成target实例以及对应的action选择子,然后最终调用到目标业务提供的逻辑,完成需求。


在远程应用调用中,远程应用通过openURL的方式,由iOS系统根据info.plist里的scheme配置找到可以响应URL的应用(在当前我们讨论的上下文中,这就是你自己的应用),应用通过AppDelegate接收到URL之后,调用CTMediatoropenUrl:方法将接收到的URL信息传入。当然,CTMediator也可以用openUrl:options:的方式顺便把随之而来的option也接收,这取决于你本地业务执行逻辑时的充要条件是否包含option数据。传入URL之后,CTMediator通过解析URL,将请求路由到对应的target和action,随后的过程就变成了上面说过的本地应用调用的过程了,最终完成响应。


针对请求的路由操作很少会采用本地文件记录路由表的方式,服务端经常处理这种业务,在服务端领域基本上都是通过正则表达式来做路由解析。App中做路由解析可以做得简单点,制定URL规范就也能完成,最简单的方式就是scheme://target/action这种,简单做个字符串处理就能把target和action信息从URL中提取出来了。




组件仅通过Action暴露可调用接口


所有组件都通过组件自带的Target-Action来响应,也就是说,模块与模块之间的接口被固化在了Target-Action这一层,避免了实施组件化的改造过程中,对Business的侵入,同时也提高了组件化接口的可维护性。


            --------------------------------
            |                              |
            |           Business A         |
            |                              |
            ---  ----------  ----------  ---
              |  |        |  |        |  |
              |  |        |  |        |  |
   ...........|  |........|  |........|  |...........
   .          |  |        |  |        |  |          .
   .          |  |        |  |        |  |          .
   .        ---  ---    ---  ---    ---  ---        .
   .        |      |    |      |    |      |        .
   .        |action|    |action|    |action|        .
   .        |      |    |      |    |      |        .
   .        ---|----    -----|--    --|-----        .
   .           |             |        |             .
   .           |             |        |             .
   .       ----|------     --|--------|--           .
   .       |         |     |            |           .
   .       |Target_A1|     |  Target_A2 |           .
   .       |         |     |            |           .
   .       -----------     --------------           .
   .                                                .
   .                                                .
   ..................................................


大家可以看到,虚线圈起来的地方就是用于跨组件调用的target和action,这种方式避免了由BusinessA直接提供组件间调用会增加的复杂度,而且任何组件如果想要对外提供调用服务,直接挂上target和action就可以了,业务本身在大多数场景下去进行组件化改造时,是基本不用动的。



复杂参数和非常规参数,以及组件化相关设计思路


这里我们需要针对术语做一个理解上的统一:

复杂参数是指由普通类型的数据组成的多层级参数。在本文中,我们定义只要是能够被json解析的类型就都是普通类型,包括NSNumber, NSString, NSArray, NSDictionary,以及相关衍生类型,比如来自系统的NSMutableArray或者你自己定义的都算。

总结一下就是:在本文讨论的场景中,复杂参数的定义是由普通类型组成的具有复杂结构的参数。普通类型的定义就是指能够被json解析的类型。

非常规参数是指由普通类型以外的类型组成的参数,例如UIImage等这些不能够被json解析的类型。然后这些类型组成的参数在文中就被定义为非常规参数

总结一下就是:非常规参数是包含非常规类型的参数。非常规类型的定义就是不能被json解析的类型都叫非常规类型。



边界情况:


  • 假设多层级参数中有存在任何一个内容是非常规参数,本文中这种参数就也被认为是非常规参数。



  • 如果某个类型当前不能够被json解析,但通过某种转化方式能够转化成json,那么这种类型在场景上下文中,我们也称为普通类型。


举个例子就是通过json描述的自定义view。如果这个view能够通过某个组件被转化成json,那么即使这个view本身并不是普通类型,在具有转化器的上下文场景中,我们依旧认为它是普通类型。



  • 如果上下文场景中没有转化器,这个view就是非常规类型了。



  • 假设转化出的json不能够被还原成view,比如组件A有转化器,组件B中没有转化器,因此在组件间调用过程中json在B组件里不能被还原成view。在这种调用方向中,只要调用者能将非常规类型转化成json的,我们就依然认为这个view是普通类型。如果调用者是组件A,转化器在组件B中,A传递view参数时是没办法转化成json的,那么这个view就被认为是非常规类型,哪怕它在组件B中能够被转化成json。




然后我来解释一下为什么应该由本地组件间调用来支持远程应用调用:


在远程App调用时,远程App是不可能通过URL来提供非常规参数的,最多只能以json string的方式经过URLEncode之后再通过GET来提供复杂参数,然后再在本地组件中解析json,最终完成调用。在组件间调用时,通过performTarget:action:params:是能够提供非常规参数的,于是我们可以知道,远程App调用时的上下文环境以及功能是本地组件间调用时上下文环境以及功能的子集


因此这个逻辑注定了必须由本地组件间调用来为远程App调用来提供服务,只有符合这个逻辑的设计思路才是正确的组件化方案的设计思路,其他跟这个不一致的思路一定就是错的。因为逻辑上子集为父集提供服务说不通,所以强行这么做的话,用一个成语来总结就叫做倒行逆施。


另外,远程App调用和本地组件间调用必须要拆分开,远程App调用只能走CTMediator提供的专用远程的方法,本地组件间调用只能走CTMediator提供的专用本地的方法,两者不能通过同一个接口来调用。

这里有两个原因:


  • 远程App调用处理入参的过程比本地多了一个URL解析的过程,这是远程App调用特有的过程。这一点我前面说过,这里我就不细说了。

  • 架构师没有充要条件条件可以认为远程App调用对于无响应请求的处理方式和本地组件间调用无响应请求的处理方式在未来产品的演进过程中是一致的


在远程App调用中,用户通过url进入app,当app无法为这个url提供服务时,常见的办法是展示一个所谓的404界面,告诉用户"当前没有相对应的内容,不过你可以在app里别的地方再逛逛"。这个场景多见于用户使用的App版本不一致。比如有一个URL只有1.1版本的app能完整响应,1.0版本的app虽然能被唤起,但是无法完成整个响应过程,那么1.0的app就要展示一个404了。





在组件间调用中,如果遇到了无法响应的请求,就要分两种场景考虑了。



场景1


如果这种无法响应的请求发生场景是在开发过程中,比如两个组件同时在开发,组件A调用组件B时,组件B还处于旧版本没有发布新版本,因此响应不了,那么这时候的处理方式可以相对随意,只要能体现B模块是旧版本就行了,最后在RC阶段统测时是一定能够发现的,只要App没发版,怎么处理都来得及。



场景2


如果这种无法响应的请求发生场景是在已发布的App中,有可能展示个404就结束了,那这就跟远程App调用时的404处理场景一样。但也有可能需要为此做一些额外的事情,有可能因为做了额外的事情,就不展示404了,展示别的页面了,这一切取决于产品经理。



那么这种场景是如何发生的呢?



我举一个例子:当用户在1.0版本时收藏了一个东西,然后用户升级App到1.1版本。1.0版本的收藏项目在本地持久层存入的数据有可能是会跟1.1版本收藏时存入的数据是不一致的。此时用户在1.1版本的app中对1.0版本收藏的东西做了一些操作,触发了本地组件间调用,这个本地间调用又与收藏项目本身的数据相关,那么这时这个调用就是有可能变成无响应调用,此时的处理方式就不见得跟以前一样展示个404页面就结束了,因为用户已经看到了收藏了的东西,结果你还告诉他找不到,用户立刻懵逼。。。这时候的处理方式就会用很多种,至于产品经理会选择哪种,你作为架构师是没有办法预测的。如果产品经理提的需求落实到架构上,对调用入口产生要求然而你的架构又没有拆分调用入口,对于你的选择就只有两个:要么打回产品需求,要么加个班去拆分调用入口。


当然,架构师可以选择打回产品经理的需求,最终挑选一个自己的架构能够承载的需求。但是,如果这种是因为你早期设计架构时挖的坑而打回的产品需求,你不觉得丢脸么?


鉴于远程app调用和本地组件间调用下的无响应请求处理方式不同,以及未来不可知的产品演进,拆分远程app调用入口和本地组件间调用入口是功在当代利在千秋的事情。








组件化方案中的去model设计



组件间调用时,是需要针对参数做去model化的。如果组件间调用不对参数做去model化的设计,就会导致业务形式上被组件化了,实质上依然没有被独立


假设模块A和模块B之间采用model化的方案去调用,那么调用方法时传递的参数就会是一个对象。


如果对象不是一个面向接口的通用对象,那么mediator的参数处理就会非常复杂,因为要区分不同的对象类型。如果mediator不处理参数,直接将对象以范型的方式转交给模块B,那么模块B必然要包含对象类型的声明。假设对象声明放在模块A,那么B和A之间的组件化只是个形式主义。如果对象类型声明放在mediator,那么对于B而言,就不得不依赖mediator。但是,大家可以从上面的架构图中看到,对于响应请求的模块而言,依赖mediator并不是必要条件,因此这种依赖是完全不需要的,这种依赖的存在对于架构整体而言,是一种污染。




如果参数是一个面向接口的对象,那么mediator对于这种参数的处理其实就没必要了,更多的是直接转给响应方的模块。而且接口的定义就不可能放在发起方的模块中了,只能放在mediator中。响应方如果要完成响应,就也必须要依赖mediator,然而前面我已经说过,响应方对于mediator的依赖是不必要的,因此参数其实也并不适合以面向接口的对象的方式去传递。




因此,使用对象化的参数无论是否面向接口,带来的结果就是业务模块形式上是被组件化了,但实质上依然没有被独立。




在这种跨模块场景中,参数最好还是以去model化的方式去传递,在iOS的开发中,就是以字典的方式去传递。这样就能够做到只有调用方依赖mediator,而响应方不需要依赖mediator。然而在去model化的实践中,由于这种方式自由度太大,我们至少需要保证调用方生成的参数能够被响应方理解,然而在组件化场景中,限制去model化方案的自由度的手段,相比于网络层和持久层更加容易得多。


因为组件化天然具备了限制手段:参数不对就无法调用!无法调用时直接debug就能很快找到原因。所以接下来要解决的去model化方案的另一个问题就是:如何提高开发效率。


在去model的组件化方案中,影响效率的点有两个:调用方如何知道接收方需要哪些key的参数?调用方如何知道有哪些target可以被调用?其实后面的那个问题不管是不是去model的方案,都会遇到。为什么放在一起说,因为我接下来要说的解决方案可以把这两个问题一起解决。




解决方案就是使用category


mediator这个repo维护了若干个针对mediator的category,每一个对应一个target,每个category里的方法对应了这个target下所有可能的调用场景,这样调用者在包含mediator的时候,自动获得了所有可用的target-action,无论是调用还是参数传递,都非常方便。接下来我要解释一下为什么是category而不是其他:


  • category本身就是一种组合模式,根据不同的分类提供不同的方法,此时每一个组件就是一个分类,因此把每个组件可以支持的调用用category封装是很合理的。

  • 在category的方法中可以做到参数的验证,在架构中对于保证参数安全是很有必要的。当参数不对时,category就提供了补救的入口。

  • category可以很轻松地做请求转发,如果不采用category,请求转发逻辑就非常难做了。

  • category统一了所有的组件间调用入口,因此无论是在调试还是源码阅读上,都为工程师提供了极大的方便。

  • 由于category统一了所有的调用入口,使得在跨模块调用时,对于param的hardcode在整个App中的作用域仅存在于category中,在这种场景下的hardcode就已经变成和调用宏或者调用声明没有任何区别了,因此是可以接受的。


这里是业务方使用category调用时的场景,大家可以看到非常方便,不用去记URL也不用纠结到底应该传哪些参数。


    if (indexPath.row == 0) {
        UIViewController *viewController = [[CTMediator sharedInstance] CTMediator_viewControllerForDetail];

        // 获得view controller之后,在这种场景下,到底push还是present,其实是要由使用者决定的,mediator只要给出view controller的实例就好了
        [self presentViewController:viewController animated:YES completion:nil];
    }

    if (indexPath.row == 1) {
        UIViewController *viewController = [[CTMediator sharedInstance] CTMediator_viewControllerForDetail];
        [self.navigationController pushViewController:viewController animated:YES];
    }

    if (indexPath.row == 2) {
        // 这种场景下,很明显是需要被present的,所以不必返回实例,mediator直接present了
        [[CTMediator sharedInstance] CTMediator_presentImage:[UIImage imageNamed:@"image"]];
    }

    if (indexPath.row == 3) {
        // 这种场景下,参数有问题,因此需要在流程中做好处理
        [[CTMediator sharedInstance] CTMediator_presentImage:nil];
    }

    if (indexPath.row == 4) {
        [[CTMediator sharedInstance] CTMediator_showAlertWithMessage:@"casa" cancelAction:nil confirmAction:^(NSDictionary *info) {
            // 做你想做的事
            NSLog(@"%@", info);
        }];
    }


本文对应的demo展示了如何使用category来实现去model的组件调用。上面的代码片段也是摘自这个demo。








基于其他考虑还要再做的一些额外措施



基于安全考虑


我们需要防止黑客通过URL的方式调用本属于native的组件,比如支付宝的个人财产页面。如果在调用层级上没有区分好,没有做好安全措施,黑客就有通过safari查看任何人的个人财产的可能。


安全措施其实有很多,大部分取决于App本身以及产品的要求。在架构层面要做的最基础的一点就是区分调用是来自于远程App还是本地组件,我在demo中的安全措施是采用给action添加native前缀去做的,凡是带有native前缀的就都只允许本地组件调用,如果在url阶段发现调用了前缀为native的方法,那就可以采取响应措施了。这也是将远程app调用入口和本地组件调用入口区分开来的重要原因之一。

当然,为了确保安全的做法有很多,但只要拆出远程调用和本地调用,各种做法就都有施展的空间了。



基于动态调度考虑


动态调度的意思就是,今天我可能这个跳转是要展示A页面,但是明天可能同样的跳转就要去展示B页面了。这个跳转有可能是来自于本地组件间跳转也有可能是来自于远程app。


做这个事情的切点在本文架构中,有很多个:


  1. 以url parse为切点
  2. 以实例化target时为切点
  3. 以category调度方法为切点
  4. 以target下的action为切点


如果以url parse为切点的话,那么这个动态调度就只能够对远程App跳转产生影响,失去了动态调度本地跳转的能力,因此是不适合的。


如果以实例化target时为切点的话,就需要在代码中针对所有target都做一次审查,看是否要被调度,这是没必要的。假设10个调用请求中,只有1个要被动态调度,那么就必须要审查10次,只有那1次审查通过了,才走动态调度,这是一种相对比较粗暴的方法。


如果以category调度方法为切点的话,那动态调度就只能影响到本地件组件的跳转,因为category是只有本地才用的,所以也不适合。


以target下的action为切点是最适合的,因为动态调度在一般场景下都是有范围的,大多数是活动页需要动态调度,今天这个活动明天那个活动,或者今天活动正在进行明天活动就结束了,所以产生动态调度的需求。我们在可能产生动态调度的action中审查当前action是否需要被动态调度,在常规调度中就没必要审查了,例如个人主页的跳转,商品详情的跳转等,这样效率就能比较高。


大家会发现,如果要做类似这种效率更高的动态调度,target-action层被抽象出来就是必不可少的,然而蘑菇街并没有抽象出target-action层,这也是其中的一个问题。


当然,如果你的产品要求所有页面都是存在动态调度需求的,那就还是以实例化target时为切点去调度了,这样能做到审查每一次调度请求,从而实现动态调度。



说完了调度切点,接下来要说的就是如何完成审查流程。完整的审查流程有几种,我每个都列举一下:


  1. App启动时下载调度列表,或者定期下载调度列表。然后审查时检查当前action是否存在要被动态调度跳转的action,如果存在,则跳转到另一个action
  2. 每一次到达新的action时,以action为参数调用API获知是否需要被跳转,如果需要被跳转,则API告知要跳转的action,然后再跳转到API指定的action


这两种做法其实都可以,如果产品对即时性的要求比较高,那么采用第二种方案,如果产品对即时性要求不那么高,第一种方案就可以了。由于本文的方案是没有URL注册列表的,因此服务器只要给出原始target-action和对应跳转的target-action就可以了,整个流程不是只有注册URL列表才能达成的,而且这种方案比注册URL列表要更易于维护一些。


另外,说采用url rewrite的手段来进行动态调度,也不是不可以。但是这里我需要辨析的是,URL的必要性仅仅体现在远程App调度中,是没必要蔓延到本地组件间调用的。这样,当我们做远程App的URL路由时(目前的demo没有提供URL路由功能,但是提供了URL路由操作的接入点,可以根据业务需求插入这个功能),要关心的事情就能少很多,可以比较干净。在这种场景下,单纯以URL rewrite的方式其实就与上文提到的以url parse为切点没有区别了。




相比之下,蘑菇街的组件化方案有以下缺陷



  • 蘑菇街没有拆分远程调用和本地间调用

不拆分远程调用和本地间调用,就使得后续很多手段难以实施,这个我在前文中都已经有论述了。另外再补充一下,这里的拆分不是针对来源做拆分。比如通过URL来区分是远程App调用还是本地调用,这只是区分了调用者的来源。


这里说的区分是指:远程调用走远程调用路径,也就是openUrl->urlParse->perform->target-action。本地组件间调用就走本地组件间调用路径:perform->target-action。这两个是一定要作区分的,蘑菇街方案并没有对此做好区分。



  • 蘑菇街以远程调用的方式为本地间调用提供服务

这是本末倒置的做法,倒行逆施导致的是未来架构难以为业务发展提供支撑。因为前面已经论述过,在iOS场景下,远程调用的实现是本地调用实现的子集,只有大的为小提供服务,也就是本地调用为远程调用提供服务,如果反过来就是倒行逆施了。



  • 蘑菇街的本地间调用无法传递非常规参数,复杂参数的传递方式非常丑陋

注意这里复杂参数非常规参数的辨析。


由于采用远程调用的方式执行本地调用,在前面已经论述过两者功能集的关系,因此这种做法无法满足传递非常规参数的需求。而且如果基于这种方式不变的话,复杂参数的传递也只能依靠经过urlencode的json string进行,这种方式非常丑陋,而且也不便于调试。



  • 蘑菇街必须要在app启动时注册URL响应者

这个条件在组件化方案中是不必要条件,demo也已经证实了这一点。这个不必要的操作会导致不必要的维护成本,如果单纯从只要完成业务就好的角度出发,这倒不是什么大问题。这就看架构师对自己是不是要求严格了。



  • 新增组件化的调用路径时,蘑菇街的操作相对复杂

在本文给出的组件化方案中,响应者唯一要做的事情就是提供Target和Action,并不需要再做其它的事情。蘑菇街除此之外还要再做很多额外不必要措施,才能保证调用成功。



  • 蘑菇街没有针对target层做封装

这种做法使得所有的跨组件调用请求直接hit到业务模块,业务模块必然因此变得臃肿难以维护,属于侵入式架构。应该将原本属于调用相应的部分拿出来放在target-action中,才能尽可能保证不将无关代码侵入到原有业务组件中,才能保证业务组件未来的迁移和修改不受组件调用的影响,以及降低为项目的组件化实施而带来的时间成本。




总结



本文提供的组件化方案是采用Mediator模式和苹果体系下的Target-Action模式设计的。


然而这款方案有一个很小的缺陷在于对param的key的hardcode,这是为了达到最大限度的解耦和灵活度而做的权衡。在我的网络层架构和持久层架构中,都没有hardcode的场景,这也从另一个侧面说明了组件化架构的特殊性。


权衡时,考虑到这部分hardcode的影响域仅仅存在于mediator的category中。在这种情况下,hardcode对于调用者的调用是完全透明的。对于响应者而言,处理方式等价于对API返回的参数的处理方式,且响应者的处理方式也被限制在了Action中


因此这部分的hardcode的存在虽然确实有点不干净,但是相比于这些不干净而带来的其他好处而言,在权衡时是可以接受的,如果不采用hardcode,那势必就会导致请求响应方也需要依赖mediator,然而这在逻辑上是不必要的。另外,在我的各个项目的实际使用过程中,这部分hardcode是没有影响的。


另外要谈的是,之所以会在组件化方案中出现harcode,而网络层和持久层的去model化都没有发生hardcode情况,是因为组件化调用的所有接受者和调用者都在同一片上下文里。网络层有一方在服务端,持久层有一方在数据库。再加上设计时针对hardcode部分的改进手段其实已经超出了语言本身的限制。也就是说,harcode受限于语言本身。objective-C也好,swift也好,它们的接口设计哲学是存在缺陷的。如果我们假设在golang的背景下,是完全可以用golang的接口体系去做一个最优美的架构方案出来的。不过这已经不属于本文的讨论范围了,有兴趣的同学可以去了解一下相关知识。架构设计有时就是这么无奈。


组件化方案在App业务稳定,且规模(业务规模和开发团队规模)增长初期去实施非常重要,它助于将复杂App分而治之,也有助于多人大型团队的协同开发。但组件化方案不适合在业务不稳定的情况下过早实施,至少要等产品已经经过MVP阶段时才适合实施组件化。因为业务不稳定意味着链路不稳定,在不稳定的链路上实施组件化会导致将来主业务产生变化时,全局性模块调度和重构会变得相对复杂。

当决定要实施组件化方案时,对于组件化方案的架构设计优劣直接影响到架构体系能否长远地支持未来业务的发展,对App的组件化不只是仅仅的拆代码和跨业务调页面,还要考虑复杂和非常规业务参数参与的调度,非页面的跨组件功能调度,组件调度安全保障,组件间解耦,新旧业务的调用接口修改等问题。


蘑菇街的组件化方案只实现了跨业务页面调用的需求,本质上只实现了我在view层架构的文章中跨业务页面调用的内容,这还没有到成为组件化方案的程度,且蘑菇街的组件化方案距离真正的App组件化的要求还是差了一段距离的,且存在设计逻辑缺陷,希望蘑菇街能够加紧重构,打造真正的组件化方案。




2016-03-14 20:26 补


没想到limboy如此迅速地发文回应了。文章地址在这里:蘑菇街 App 的组件化之路 续。然后我花了一些时间重新看了limboy的第一篇文章。我觉得在本文开头我对蘑菇街的组件化方案描述过于简略了,而且我还忽略了原来是有ModuleManager的,所以在这里我重新描述一番。



蘑菇街是以两种方式来做跨组件操作的


第一种是通过MGJRouterregisterURLPattern:toHandler:进行注册,将URL和block绑定。这个方法前面一个参数传递的是URL,例如mgj://detail?id=:id这种,后面的toHandler:传递的是一个^(NSDictionary *routerParameters){// 此处可以做任何事}的block。


当组件执行[MGJRouter openURL:@"mgj://detail?id=404"]时,根据之前registerURLPattern:toHandler:的信息,找到之前通过toHandler:收集的block,然后将URL中带的GET参数,此处是id=404,传入block中执行。如果在block中执行NSLog(routerParameters)的话,就会看到@{@"id":@"404"},因此block中的业务就能够得到执行。


然后为了业务方能够不生写URL,蘑菇街列出了一系列宏或者字符串常量(具体是宏还是字符串我就不是很确定,没看过源码,但limboy文章中有提到通过一个后台系统生成一个装满URL的源码文件)来表征URL。在openURL时,无论是远程应用调用还是本地组件间调用,只要传递的参数不复杂,就都会采用openURL的方式去唤起页面,因为复杂的参数和非常规参数这种调用方式就无法支持了。


缺陷在于:这种注册的方式其实是不必要的,而且还白白使用URLblock占用了内存。另外还有一个问题就是,即便是简单参数的传递,如果参数比较多,业务工程师不看原始URL字符串是无法知道要传递哪些参数的。


蘑菇街之所以采用id=:id的方式,我猜是为了怕业务工程师传递多个参数顺序不同会导致问题,而使用的占位符。这种做法在持久层生成sql字符串时比较常见。不过这个功能我没在limboy的文章中看到有写,不知道实现了没有。


在本文提供的组件化方案中,因为没有注册,所以就没有内存的问题。因为通过category提供接口调用,就没有参数的问题。对于蘑菇街来说,这种做法其实并没有做到拆分远程应用调用和本地组件间调用的目的,而不拆分会导致的问题我在文章中已经论述过了,这里就不多说了。




由于前面openURL的方式不能够传递非常规参数,因此有了第二种注册方式:新开了一个对象叫做ModuleManager,提供了一个registerClass:forProtocol:的方法,在应用启动时,各组件都会有一个专门的ModuleEntry被唤起,然后ModuleEntry@protocolClass进行配对。因此ModuleManager中就有了一个字典来记录这个配对。


当有涉及非常规参数的调用时,业务方就不会去使用[MGJRouter openURL:@"mgj://detail?id=404"]的方案了,转而采用ModuleManagerclassForProtocol:方法。业务传入一个@protocolModuleManager,然后ModuleManager通过之前注册过的字典查找到对应的Class返回给业务方,然后业务方再自己执行allocinit方法得到一个符合刚才传入@protocol的对象,然后再执行相应的逻辑。


这里的ModuleManager其实跟之前的MGJRouter一样,是没有任何必要去注册协议和类名的。而且无论是服务提供者调用registerClass:forProtocol:也好,服务的调用者调用classForProtocol:,都必须依赖于同一个protocol。蘑菇街把所有的protocol放入了一个publicProtocol.h的文件中,因此调用方和响应方都必须依赖于同一个文件。这个我在文章中也论述过:响应方在提供服务的时候,是不需要依赖任何人的。





所以针对蘑菇街的这篇文章我是这么回应的:


  • 蘑菇街所谓分开了远程应用调用和本地组件调用是不成立的,蘑菇街分开的只是普通参数调用非常规参数调用。不去区分远程应用调用和本地组件间调用的缺陷我在文中已经论述过了,这里不多说。


  • 蘑菇街确实不只有openURL方式,还提供了ModuleManager方式,然而所谓的我们其实是分为「组件间调用」和「页面间跳转」两个维度,只要 app 响应某个 URL,无论是 app 内还是 app 外都可以,而「组件间」调用走的完全是另一条路,所以也不会有安全上的问题。其实也是不成立的,因为openURL方式也出现在了本地组件间调用中,这在他第一篇文章里的组件间通信小节中就已经说了采用openURL方式调用了,这是有可能产生安全问题的。而且这段话也承认了openURL方式被用于本地组件间调用,又印证了我刚才说的第一点。


  • 根据上面两点,蘑菇街在openURL场景下,还是出现了以远程调用的方式为本地间调用提供服务的问题,这个问题我也已经在文中论述过了。


  • 蘑菇街在本地间调用同时采用了openURL方案和protocol - class方案,所以其实之前我指出蘑菇街本地间调用不能传递非常规参数和复杂参数是不对的,应该是蘑菇街在本地间调用时如果是普通参数,那就采用openURL,如果是非常规参数,那就采用protocol - class了,这个做法对于本地间调用的管理和维护,显而易见是不利的。。。


  • limboy说必须要在 app 启动时注册 URL 响应者这步不可避免,但没有说原因。我的demo已经证实了注册是不必要的,所以我想听听limboy如何解释原因。



  • 你的架构图画错了


mgj


按照你的方案来看,红圈的地方是不可能没有依赖的。。。




另外,limboy也对本文方案提出了一些看法:



认为category在某种意义上也是一个注册过程。


蘑菇街的注册和我这里的category其实是两回事,而且我无论如何也无法理解把category和注册URL等价联系的逻辑😂


一个很简单的事实就可以证明两者完全不等价了:我的方案如果没有category,照样可以跑,就是业务方调用丑陋一点。蘑菇街如果不注册URL,整个流程就跑不起来了~




认为openURL的好处是可以更少地关心业务逻辑,本文方案的好处是可以很方便地完成参数传递。


我没觉得本文方案关心的业务逻辑比openURL更多,因为两者比较起来,都是传参数发调用请求,在关心业务逻辑的条件下,两者完全一样。唯一的不同就是,我能传非常规参数而openURL不能。本文方案的整个过程中,在调用者这一方是完全没有涉及到任何属于响应者的业务逻辑的。




认为protocol/URL注册将target-action抽象出调用接口是等价的


这其实只是效果等价了,两者真正的区别在于:protocol对业务产生了侵入,且不符合黑盒模型。



  • 我来解释一下protocol侵入业务的原因


由于业务中的某个对象需要被调用,因此必须要符合某个可被调用的protocol,然而这个protocol又不存在于当前业务领域,于是当前业务就不得不依赖publicProtocol。这对于将来的业务迁移是有非常大的影响的。




  • 另外再解释一下为什么不符合黑盒模型


蘑菇街的protocol方式使对象要在调用者处使用,由于调用者并不包含对象原本所处的业务领域,当完成任务需要多个这样的对象的时候,就需要多次通过protocol获得class来实例化多个对象,最终才能完成需求。


但是target-action模式保证了在执行组件间调用的响应时,执行的上下文处于响应者环境中,这跟蘑菇街的protocol方案相比就是最大的差别。因为从黑盒理论上讲,调用者只管发起请求,请求的执行应该由响应者来负责,因此执行逻辑必须存在于响应者的上下文内,而不能存在于调用者的上下文内。


举个具体一点的例子就是,当你发起了一个网页请求,后端取好数据渲染好页面,无论获取数据涉及多少渠道,获取数据的逻辑都在服务端完成,然后再返回给浏览器展示。这个是正确的做法,target-action模式也是这么做的。


但是蘑菇街的方案就变成了这样:你发起了一个网络请求,后端返回的不是数据,返回的竟然是一个数据获取对象(DAO),然后你再通过DAO去取数据,去渲染页面,如果渲染页面的过程涉及多个DAO,那么你还要再发起更多请求,拿到的还是DAO,然后再拿这个DAO去获取数据,然后渲染页面。这是一种非常诡异的做法。。。


如果说这么做是为了应对执行业务的过程中,需要根据中间阶段的返回值来决定接下来的逻辑走向的话,那也应该是多次调用获得数据,然后决定接下来的业务走向,而不是每次拿到的都是DAO啊。。。使用target-action方式来应对这种场景其实也很自然啊~



所以综上所述,蘑菇街的方案是存在很大问题的,希望蘑菇街继续改正




iOS应用架构谈 本地持久化方案及动态部署

作者 Casa Taloyum
2015年10月12日 00:00




iOS应用架构谈 开篇
iOS应用架构谈 view层的组织和调用方案
iOS应用架构谈 网络层设计方案
iOS应用架构谈 本地持久化方案及动态部署
iOS应用架构谈 组件化方案




前言


嗯,你们要的大招。跟着这篇文章一起也发布了CTPersistanceCTJSBridge这两个库,希望大家在实际使用的时候如果遇到问题,就给我提issue或者PR或者评论区。每一个issue和PR以及评论我都会回复的。


持久化方案不管是服务端还是客户端,都是一个非常值得讨论的话题。尤其是在服务端,持久化方案的优劣往往都会在一定程度上影响到产品的性能。然而在客户端,只有为数不多的业务需求会涉及持久化方案,而且在大多数情况下,持久化方案对性能的要求并不是特别苛刻。所以我在移动端这边做持久化方案设计的时候,考虑更多的是方案的可维护和可拓展,然后在此基础上才是性能调优。这篇文章中,性能调优不会单独开一节来讲,而会穿插在各个小节中,大家有心的话可以重点看一下。

持久化方案对整个App架构的影响和网络层方案对整个架构的影响类似,一般都是导致整个项目耦合度高的罪魁祸首。而我也是一如既往的去Model化的实践者,在持久层去Model化的过程中,我引入了Virtual Record的设计,这个在文中也会详细描述。

这篇文章主要讲以下几点:


  1. 根据需求决定持久化方案
  2. 持久层与业务层之间的隔离
  3. 持久层与业务层的交互方式
  4. 数据迁移方案
  5. 数据同步方案


另外,针对数据库存储这一块,我写了一个CTPersistance,这个库目前能够完成大部分的持久层需求,同时也是我的Virtual Record这种设计思路的一个样例。这个库可以直接被cocoapods引入,希望大家使用的时候,能够多给我提issue。这里是CTPersistance Class Reference




根据需求决定持久化方案



在有需要持久化需求的时候,我们有非常多的方案可供选择:NSUserDefault、KeyChain、File,以及基于数据库的无数子方案。因此,当有需要持久化的需求的时候,我们首先考虑的是应该采用什么手段去进行持久化。



NSUserDefault


一般来说,小规模数据,弱业务相关数据,都可以放到NSUserDefault里面,内容比较多的数据,强业务相关的数据就不太适合NSUserDefault了。另外我想吐槽的是,天猫这个App其实是没有一个经过设计的数据持久层的。然后天猫里面的持久化方案就很混乱,我就见到过有些业务线会把大部分业务数据都塞到NSUserDefault里面去,当时看代码的时候我特么就直接跪了。。。问起来为什么这么做?结果说因为写起来方便~你妹。。。



keychain


Keychain是苹果提供的带有可逆加密的存储机制,普遍用在各种存密码的需求上。另外,由于App卸载只要系统不重装,Keychain中的数据依旧能够得到保留,以及可被iCloud同步的特性,大家都会在这里存储用户唯一标识串。所以有需要加密、需要存iCloud的敏感小数据,一般都会放在Keychain。



文件存储


文件存储包括了Plist、archive、Stream等方式,一般结构化的数据或者需要方便查询的数据,都会以Plist的方式去持久化。Archive方式适合存储平时不太经常使用但很大量的数据,或者读取之后希望直接对象化的数据,因为Archive会将对象及其对象关系序列化,以至于读取数据的时候需要Decode很花时间,Decode的过程可以是解压,也可以是对象化,这个可以根据具体<NSCoding>中的实现来决定。Stream就是一般的文件存储了,一般用来存存图片啊啥的,适用于比较经常使用,然而数据量又不算非常大的那种。



数据库存储


数据库存储的话,花样就比较多了。苹果自带了一个Core Data,当然业界也有无数替代方案可选,不过真正用在iOS领域的除了Core Data外,就是FMDB比较多了。数据库方案主要是为了便于增删改查,当数据有状态类别的时候最好还是采用数据库方案比较好,而且尤其是当这些状态类别都是强业务相关的时候,就更加要采用数据库方案了。因为你不可能通过文件系统遍历文件去甄别你需要获取的属于某个状态类别的数据,这么做成本就太大了。当然,特别大量的数据也不适合直接存储数据库,比如图片或者文章这样的数据,一般来说,都是数据库存一个文件名,然后这个文件名指向的是某个图片或者文章的文件。如果真的要做全文索引这种需求,建议最好还是挂个API丢到服务端去做。



总的说一下


NSUserDefault、Keychain、File这些持久化方案都非常简单基础,分清楚什么时候用什么就可以了,不要像天猫那样乱写就好。而且在这之上并不会有更复杂的衍生需求,如果真的要针对它们写文章,无非就是写怎么储存怎么读取,这个大家随便Google一下就有了,我就不浪费笔墨了。由于大多数衍生复杂需求都是通过采用基于数据库的持久化方案去满足,所以这篇文章的重点就数据库相关的架构方案设计和实现。如果文章中有哪些问题我没有写到的,大家可以在评论区提问,我会一一解答或者直接把遗漏的内容补充在文章中。




持久层实现时要注意的隔离



在设计持久层架构的时候,我们要关注以下几个方面的隔离:


  1. 持久层与业务层的隔离
  2. 数据库读写隔离
  3. 多线程控制导致的隔离
  4. 数据表达和数据操作的隔离


1. 持久层与业务层的隔离



关于Model


在具体讲持久层下数据的处理之前,我觉得需要针对这个问题做一个完整的分析。

View层设计中我分别提到了胖Model瘦Model的设计思路,而且告诉大家我更加倾向于胖Model的设计思路。在网络层设计里面我使用了去Model化的思路设计了APIMananger与业务层的数据交互。这两个看似矛盾的关于Model的设计思路在我接下来要提出的持久层方案中其实是并不矛盾,而且是相互配合的。在网络层设计这篇文章中,我对去Model化只给出了思路和做法,相关的解释并不多,是因为要解释这个问题涉及面会比较广,写的时候并不认为在那篇文章里做解释是最好的时机。由于持久层在这里胖Model去Model化都会涉及,所以我觉得在讲持久层的时候解释这个话题会比较好。

我在跟别的各种领域的架构师交流的时候,发现大家都会或多或少地混用ModelModel Layer的概念,然后往往导致大家讨论的问题最后都不在一个点上,说Model的时候他跟你说Model Layer,那好吧,我就跟你说Model Layer,结果他又在说Model,于是问题就讨论不下去了。我觉得作为架构师,如果不分清楚这两个概念,肯定是会对你设计的架构的质量有很大影响的。

如果把Model说成Data Model,然后跟Model Layer放在一起,这样就能够很容易区分概念了。



Data Model


Data Model这个术语针对的问题领域是业务数据的建模,以及代码中这一数据模型的表征方式。两者相辅相承:因为业务数据的建模方案以及业务本身特点,而最终决定了数据的表征方式。同样操作一批数据,你的数据建模方案基本都是细化业务问题之后,抽象得出一个逻辑上的实体。在实现这个业务时,你可以选择不同的表征方式来表征这个逻辑上的实体,比如字节流(TCP包等),字符串流(JSON、XML等),对象流。对象流又分通用数据对象(NSDictionary等),业务数据对象(HomeCellModel等)。

前面已经遍历了所有的Data Model的形式。在习惯上,当我们讨论Model化时,都是单指对象流中的业务数据对象这一种。然而去Model化就是指:更多地使用通用数据对象去表征数据,业务数据对象不会在设计时被优先考虑的一种设计倾向。这里的通用数据对象可以在某种程度上理解为范型。



Model Layer


Model Layer描述的问题领域是如何对数据进行增删改查(CURD, Create Update Read Delete),和相关业务处理。一般来说如果在Model Layer中采用瘦Model的设计思路的话,就差不多到CURD为止了。胖Model还会关心如何为需要数据的上层提供除了增删改查以外的服务,并为他们提供相应的解决方案。例如缓存、数据同步、弱业务处理等。



我的倾向


我更加倾向于去Model化的设计,在网络层我设计了reformer来实现去Model化。在持久层,我设计了Virtual Record来实现去Model化。

因为具体的Model是一种很容易引入耦合的做法,在尽可能弱化Model概念的同时,就能够为引入业务和对接业务提供充分的空间。同时,也能通过去Model的设计达到区分强弱业务的目的,这在将来的代码迁移和维护中,是至关重要的。很多设计不好的架构,就在于架构师并没有认识到区分强弱业务的重要性,所以就导致架构腐化的速度很快,越来越难维护。

所以说回来,持久层与业务层之间的隔离,是通过强弱业务的隔离达到的。而Virtual Record正是因为这种去Model化的设计,从而达到了强弱业务的隔离,进而做到持久层与业务层之间既隔离同时又能交互的平衡。具体Virtual Record是什么样的设计,我在后面会给大家分析。




2. 数据库读写隔离



在网站的架构中,对数据库进行读写分离主要是为了提高响应速度。在iOS应用架构中,对持久层进行读写隔离的设计主要是为了提高代码的可维护性。这也是两个领域要求架构师在设计架构时要求侧重点不同的一个方面。

在这里我们所谓的读写隔离并不是指将数据的读操作和写操作做隔离。而是以某一条界限为准,在这个界限以外的所有数据模型,都是不可写不可修改,或者修改属性的行为不影响数据库中的数据。在这个界限以内的数据是可写可修改的。一般来说我们在设计时划分的这个界限会和持久层与业务层之间的界限保持一致,也就是业务层从持久层拿到数据之后,都不可写不可修改,或业务层针对这一数据模型的写操作、修改操作都对数据库文件中的内容不产生作用。只有持久层中的操作才能够对数据库文件中的内容产生作用。

在苹果官方提供的持久层方案Core Data的架构设计中,并没有针对读写作出隔离,数据的结果都是以NSManagedObject扔出。所以只要业务工程师稍微一不小心动一下某个属性,NSManagedObjectContext在save的时候就会把这个修改给存进去了。另外,当我们需要对所有的增删改查操作做AOP的切片时,Core Data技术栈的实现就会非常复杂。

整体上看,我觉得Core Data相对大部分需求而言是过度设计了。我当时设计安居客聊天模块的持久层时就采用了Core Data,然后为了读写隔离,将所有扔出来的NSManagedObject都转为了普通的对象。另外,由于聊天记录的业务相当复杂,使用Core Data之后为了完成需求不得不引入很多Hack的手段,这种做法在一定程度上降低了这个持久层的可维护性和提高了接手模块的工程师的学习曲线,这是不太好的。在天猫客户端,我去的时候天猫这个App就已经属于基本毫无持久层可言了,比较混乱。只能依靠各个业务线各显神通去解决数据持久化的需求,难以推动统一的持久层方案,这对于项目维护尤其是跨业务项目合作来说,基本就和车祸现场没啥区别。我现在已经从天猫离职,读者中若是有阿里人想升职想刷存在感拿3.75的,可以考虑给天猫搞个统一的持久层方案。

读写隔离还能够便于加入AOP切点,因为针对数据库的写操作被隔离到一个固定的地方,加AOP时就很容易在正确的地方放入切片。这个会在讲到数据同步方案时看到应用。




3. 多线程导致的隔离



Core Data


Core Data要求在多线程场景下,为异步操作再生成一个NSManagedObjectContext,然后设置它的ConcurrencyTypeNSPrivateQueueConcurrencyType,最后把这个Context的parentContext设为Main线程下的Context。这相比于使用原始的SQLite去做多线程要轻松许多。只不过要注意的是,如果要传递NSManagedObject的时候,不能直接传这个对象的指针,要传NSManagedObjectID。这属于多线程环境下对象传递的隔离,在进行架构设计的时候需要注意。



SQLite


纯SQLite其实对于多线程倒是直接支持,SQLite库提供了三种方式:Single ThreadMulti ThreadSerialized

Single Thread模式不是线程安全的,不提供任何同步机制。Multi Thread模式要求database connection不能在多线程中共享,其他的在使用上就没什么特殊限制了。Serialized模式顾名思义就是由一个串行队列来执行所有的操作,对于使用者来说除了响应速度会慢一些,基本上就没什么限制了。大多数情况下SQLite的默认模式是Serialized

根据Core Data在多线程场景下的表现,我觉得Core Data在使用SQLite作为数据载体时,使用的应该就是Multi Thread模式。SQLite在Multi Thread模式下使用的是读写锁,而且是针对整个数据库加锁,不是表锁也不是行锁,这一点需要提醒各位架构师注意。如果对响应速度要求很高的话,建议开一个辅助数据库,把一个大的写入任务先写入辅助数据库,然后拆成几个小的写入任务见缝插针地隔一段时间往主数据库中写入一次,写完之后再把辅助数据库删掉。

不过从实际经验上看,本地App的持久化需求的读写操作一般都不会大,只要注意好几个点之后一般都不会影响用户体验。因此相比于Multi Thread模式,Serialized模式我认为是性价比比较高的一种选择,代码容易写容易维护,性能损失不大。为了提高几十毫秒的性能而牺牲代码的维护性,我是觉得划不来的。



Realm


关于Realm我还没来得及仔细研究,所以说不出什么来。




4. 数据表达和数据操作的隔离



这是最容易被忽视的一点,数据表达和数据操作的隔离是否能够做好,直接影响的是整个程序的可拓展性。

长久以来,我们都很习惯Active Record类型的数据操作和表达方式,例如这样:


Record *record = [[Record alloc] init];
record.data = @"data";
[record save];


或者这种:


Record *record = [[Record alloc] init];
NSArray *result = [record fetchList];


简单说就是,让一个对象映射了一个数据库里的表,然后针对这个对象做操作就等同于针对这个表以及这个对象所表达的数据做操作。这里有一个不好的地方就在于,这个Record既是数据库中数据表的映射,又是这个表中某一条数据的映射。我见过很多框架(不仅限于iOS,包括Python, PHP等)都把这两者混在一起去处理。如果按照这种不恰当的方式来组织数据操作和数据表达,在胖Model的实践下会导致强弱业务难以区分从而造成非常大的困难。使用瘦Model这种实践本身就是我认为有缺点的,具体的我在开篇中已经讲过,这里就不细说了。

强弱业务不能区分带来的最大困难在于代码复用和迁移,因为持久层中的强业务对View层业务的高耦合是无法避免的,然而弱业务相对而言只对下层有耦合关系对上层并不存在耦合关系,当我们做代码迁移或者复用时,往往希望复用的是弱业务而不是强业务,若此时强弱业务分不开,代码复用就无从谈起,迁移时就倍加困难。

另外,数据操作和数据表达混在一起会导致的问题在于:客观情况下,数据在view层业务上的表达方式多种多样,有可能是个View,也有可能是个别的什么对象。如果采用映射数据库表的数据对象去映射数据,那么这种多样性就会被限制,实际编码时每到使用数据的地方,就不得不多一层转换。

我认为之所以会产生这样不好的做法原因在于,对象对数据表的映射和对象对数据表达的映射结果非常相似,尤其是在表达Column时,他们几乎就是一模一样。在这里要做好针对数据表或是针对数据的映射要做的区分的关键要点是:这个映射对象的操作着手点相对数据表而言,是对内还是对外操作。如果是对内操作,那么这个操作范围就仅限于当前数据表,这些操作映射给数据表模型就比较合适。如果是对外操作,执行这些操作时有可能涉及其他的数据表,那么这些操作就不应该映射到数据表对象中。

因此实际操作中,我是以数据表为单位去针对操作进行对象封装,然后再针对数据记录进行对象封装。数据表中的操作都是针对记录的普通增删改查操作,都是弱业务逻辑。数据记录仅仅是数据的表达方式,这些操作最好交付给数据层分管强业务的对象去执行。具体内容我在下文还会继续说。




持久层与业务层的交互方式



说到这里,就不得不说CTPersistanceVirtual Record了。我会通过它来讲解持久层与业务层之间的交互方式。


                 -------------------------------------------
                 |                                         |
                 |  LogicA     LogicB            LogicC    |    ------------------------------->    View Layer
                 |     \         /                 |       |
                 -------\-------/------------------|--------
                         \     /                   |
                          \   / Virtual            | Virtual
                           \ /  Record             | Record
                            |                      |
                 -----------|----------------------|--------
                 |          |                      |       |
  Strong Logics  |     DataCenterA            DataCenterB  |
                 |        /   \                    |       |
-----------------|-------/-----\-------------------|-------|    Data Logic Layer   ---
                 |      /       \                  |       |                         |
   Weak Logics   | Table1       Table2           Table     |                         |
                 |      \       /                  |       |                         |
                 --------\-----/-------------------|--------                         |
                          \   /                    |                                 |--> Data Persistance Layer
                           \ / Query Command       | Query Command                   |
                            |                      |                                 |
                 -----------|----------------------|--------                         |
                 |          |                      |       |                         |
                 |          |                      |       |                         |
                 |      DatabaseA              DatabaseB   |  Data Operation Layer ---
                 |                                         |
                 |             Database Pool               |
                 -------------------------------------------



我先解释一下这个图:持久层有专门负责对接View层模块或业务的DataCenter,它们之间通过Record来进行交互。DataCenter向上层提供业务友好的接口,这一般都是强业务:比如根据用户筛选条件返回符合要求的数据等。


然后DataCenter在这个接口里面调度各个Table,做一系列的业务逻辑,最终生成record对象,交付给View层业务。


DataCenter为了要完成View层交付的任务,会涉及数据组装和跨表的数据操作。数据组装因为View层要求的不同而不同,因此是强业务。跨表数据操作本质上就是各单表数据操作的组合,DataCenter负责调度这些单表数据操作从而获得想要的基础数据用于组装。那么,这时候单表的数据操作就属于弱业务,这些弱业务就由Table映射对象来完成。


Table对象通过QueryCommand来生成相应的SQL语句,并交付给数据库引擎去查询获得数据,然后交付给DataCenter。



DataCenter 和 Virtual Record


提到Virtual Record之前必须先说一下DataCenter。


DataCenter其实是一个业务对象,DataCenter是整个App中,持久层与业务层之间的胶水。它向业务层开放业务友好的接口,然后通过调度各个持久层弱业务逻辑和数据记录来完成强业务逻辑,并将生成的结果交付给业务层。由于DataCenter处在业务层和持久层之间,那么它执行业务逻辑所需要的载体,就要既能够被业务层理解,也能够被持久层理解。

CTPersistanceTable就封装了弱业务逻辑,由DataCenter调用,用于操作数据。而Virtual Record就是前面提到的一个既能够被业务层理解,也能够被持久层理解的数据载体。


Virtual Record事实上并不是一个对象,它只是一个protocol,这就是它Virtual的原因。一个对象只要实现了Virtual Record,它就可以直接被持久层当作Record进行操作,所以它也是一个Record。连起来就是Virtual Record了。所以,Virtual Record的实现者可以是任何对象,这个对象一般都是业务层对象。在业务层内,常见的数据表达方式一般都是View,所以一般来说Virutal Record的实现者也都会是一个View对象。


我们回顾一下传统的数据操作过程:一般都是先从数据库中取出数据,然后Model化成一个对象,然后再把这个模型丢到外面,让Controller转化成View,然后再执行后面的操作。

Virtual Record也是一样遵循类似的步骤。唯一不同的是,整个过程中,它并不需要一个中间对象去做数据表达,对于数据的不同表达方式,由各自Virtual Record的实现者自己完成,而不需要把这些代码放到Controller,所以这就是一个去Model化的设计。如果未来针对这个数据转化逻辑有复用的需求,直接复用Virtual Record就可以了,十分方便。


用好Virtual Record的关键在于DataCenter提供的接口对业务足够友好,有充足的业务上下文环境。

所以DataCenter一般都是被Controller所持有,所以如果整个App就只有一个DataCenter,这其实并不是一个好事。我见过有很多App的持久层就是一个全局单例,所有持久化业务都走这个单例,这是一种很蛋疼的做法。DataCenter也是需要针对业务做高度分化的,每个大业务都要提供一个DataCenter,然后挂在相关Controller下交给Controller去调度。比如分化成SettingsDataCenterChatRoomDataCenterProfileDataCenter等,另外要要注意的是,几个DataCenter之间最好不要有业务重叠。如果一个DataCenter的业务实在是大,那就再拆分成几个小业务。如果单个小业务都很大了,那就拆成各个Category,具体的做法可以参考我的框架中CTPersistanceTableCTPersistanceQueryCommand的实践。

这么一来,如果要迁移涉及持久层的强业务,那就只需要迁移DataCenter即可。如果要迁移弱业务,就只需要迁移CTPersistanceTable




实际场景



假设业务层此时收集到了用户的筛选条件:


NSDictionary *filter = @{
    @"key1":@{
        @"minValue1":@(1),
        @"maxValue1":@(9),
    },
    @"key2":@{
        @"minValue2":@(1),
        @"maxValue2":@(9),
    },
    @"key3":@{
        @"minValue3":@(1),
        @"maxValue3":@(9),
    },
};


然后ViewController调用DataCenter向业务层提供的接口,获得数据直接展示:


/* in view controller */

    NSArry *fetchedRecordList = [self.dataCenter fetchItemListWithFilter:filter]
    [self.dataList appendWithArray:fetchedRecordList];
    [self.tableView reloadData];


在View层要做的事情其实到这里就已经结束了,此时我们回过头再来看DataCenter如何实现这个业务:


/* in DataCenter */

- (NSArray *)fetchItemListWithFilter:(NSDictionary *)filter
{
    ...
    ...
    ...

    /*
    解析filter获得查询所需要的数据
    whereCondition
    whereConditionParams
    假设上面这两个变量就是解析得到的变量
    */

    ...
    ...
    ...

    /* 告知Table对象查询数据后需要转化成的对象(可选,统一返回对象可以便于归并来自不同表的数据) */
    self.itemATable.recordClass = [Item class];
    self.itemBTable.recordClass = [Item class];
    self.itemCTable.recordClass = [Item class];

    /* 通过Table对象获取数据,此时Table对象内执行的就是弱业务了 */
    NSArray *itemAList = [self.itemATable findAllWithWhereCondition:whereCondition conditionParams:whereConditionParams isDistinct:NO error:NULL];
    NSArray *itemBList = [self.itemBTable findAllWithWhereCondition:whereCondition conditionParams:whereConditionParams isDistinct:NO error:NULL];
    NSArray *itemCList = [self.itemCTable findAllWithWhereCondition:whereCondition conditionParams:whereConditionParams isDistinct:NO error:NULL];

    /* 组装数据 */
    NSMutableArray *resultList = [[NSMutableArray alloc] init];
    [resultList addObjectsFromArray:itemAList];
    [resultList addObjectsFromArray:itemBList];
    [resultList addObjectsFromArray:itemCList];

    return resultList;
}


基本上差不多就是上面这样的流程。

一般来说,架构师设计得差的持久层,都没有通过设计DataCenter和Table,去将强业务和弱业务分开。通过设计DataCenter和Table对象,主要是便于代码迁移。如果迁移强业务,把DataCenter和Table一起拿走就可以,如果只是迁移弱业务,拿走Table就可以了。


另外,通过代码我希望向你强调一下这个概念:将Table和Record区分开,这个在我之前画的架构图上已经有所表现,不过上文并没有着重强调。其实很多别的架构师在设计持久层框架的时候,也没有将Table和Record区分开,对的,这里我说的框架包括Core Data和FMDB,这个也不仅限于iOS领域,CodeIgniter、ThinkPHP、Yii、Flask这些也都没有对这个做区分。(这里吐槽一下,话说上文我还提到Core Data被过度设计了,事实上该设计的地方没设计到,不该设计的地方各种设计往上堆...)


以上就是对Virtual Record这个设计的简单介绍,接下来我们就开始讨论不同场景下如何进行交互了。

其中我们最为熟悉的一个场景是这样的:经过各种逻辑组装出一个数据对象,然后把这个数据对象交付给持久层去处理。这种场景我称之为一对一的交互场景,这个交互场景的实现非常传统,就跟大家想得那样,而且CTPersistance的test case里面都是这样的,所以这里我就不多说了。所以,既然你已经知道有了一对一,那么顺理成章地就也会有多对一,以及一对多的交互场景。

下面我会一一描述Virtual Record是如何发挥虚拟的优势去针对不同场景进行交互的。




多对一场景下,业务层如何与持久层交互?



多对一场景其实有两种理解,一种是一个记录的数据由多个View的数据组成。例如一张用户表包含用户的所有资料。然后有的View只包含用户昵称用户头像,有的对象只包含用户ID用户Token。然而这些数据都只存在一张用户表中,所以这是一种多个对象的数据组成一个完整Record数据的场景,这是多对一场景的理解之一。

第二种理解是这样的,例如一个ViewA对象包含了一个Record的所有信息,然后另一个ViewB对象其实也包含了一个Record的所有信息,这就是一种多个不同对象表达了一个Record数据的场景,这也是一种多对一场景的理解。

同时,这里所谓的交互还分两个方向:存和取。

其实这两种理解的解决方案都是一样的,Virtual Record的实现者通过实现Merge操作来完成record数据的汇总,从而实现存操作。任意Virtual Record的实现者通过Merge操作,就可以将自己的数据交付给其它不同的对象进行表达,从而实现取操作。具体的实现在下面有具体阐释。




多对一场景下,如何进行存操作?



<CTPersistanceProtocol>提供了- (NSObject <CTPersistanceRecordProtocol> *)mergeRecord:(NSObject <CTPersistanceRecordProtocol> *)record shouldOverride:(BOOL)shouldOverride;这个方法。望文生义一下,就是一个record可以与另外一个record进行merge。在shouldOverride为NO的情况下,任何一边的nil都会被另外一边不是nil的记录覆盖,如果merge过程中两个对象都不含有这些空数据,则根据shouldOverride来决定是否要让参数中record的数据覆盖自己本身的数据,若shouldOverride为YES,则即便是nil,也会把已有的值覆盖掉。这个方法会返回被Merge的这个对象,便于链式调用。

举一个代码样例:


/*
这里的RecordViewA, RecordViewB, RecordViewC都是符合<CTPersistanceRecordProtocol>且实现了- (NSObject <CTPersistanceRecordProtocol> *)mergeRecord:(NSObject <CTPersistanceRecordProtocol> *)record shouldOverride:(BOOL)shouldOverride方法。
*/

RecordViewA *a;
RecordViewB *b;
RecordViewC *c;

...
收集a, b, c的值的逻辑我就不写了
...

[[a mergeRecord:b shouldOverride:YES] mergeRecord:c shouldOverride:YES];
[self.dataCenter saveRecord:a];


基本思路就是通过merge不同的record对象来达到获取完整数据的目的,由于是Virtual Record,具体的实现都是由各自的View去决定。View是最了解自己属性的对象了,因此它是有充要条件来把自己与持久层相关的数据取出并Merge的,那么这段凑数据的代码,就相应分散到了各个View对象中,Controller里面就能够做到非常干净,整体可维护性也就提高了。

如果采用传统方式,ViewController或者DataCenter中就会散落很多用于凑数据的代码,写的时候就会出现一大段用于合并的代码,非常难看,还不容易维护。




多对一场景下,如何进行取操作?



其实这样的表述并不恰当,因为无论Virtual Record的实现如何,对象是谁,只要从数据库里面取出数据来,数据就都是能够保证完整的。这里更准确的表述是,取出数据之后,如何交付给不同的对象。其实还是用到上面提到的mergeRecord方法来处理。


/*
这里的RecordViewA, RecordViewB, RecordViewC都是符合<CTPersistanceRecordProtocol>且实现了- (NSObject <CTPersistanceRecordProtocol> *)mergeRecord:(NSObject <CTPersistanceRecordProtocol> *)record shouldOverride:(BOOL)shouldOverride方法。
*/

RecordViewA *a;
RecordViewB *b = [[RecordViewB alloc] init];
RecordViewC *c = [[RecordViewC alloc] init];

a = [self.table findLatestRecordWithError:NULL];
[b mergeRecord:a];
[c mergeRecord:a];

return @[a, b, c]


这样就能很容易把a记录的数据交给b和c了,代码观感同样非常棒,而且容易写容易维护。




一对多场景下,业务层如何与持久层交互?



一对多场景也有两种理解,其一是一个对象包含了多个表的数据,另外一个是一个对象用于展示多种表的数据,这个代码样例其实文章前面已经有过,这一节会着重强调一下。乍看之下两者并没有什么区别,所以我需要指出的是,前者强调的是包含,也就是这个对象是个大熔炉,由多个表的数据组成。

还是举用户列表的例子:


假设数据库中用户相关的表有多张。大多数情况是因为单表Column太多,所以为了提高维护性和查询性能而进行的纵切


多说一句,纵切在实际操作时,大多都是根据业务场景去切分成多个不同的表,分别来表达用户各业务相关的部分数据,所以纵切的结果就是把Column特别多的一张表拆成Column不那么多的好几个表。虽然数据库经过了纵切,但是有的场景还是要展示完整数据的,比如用户详情页。因此,这个用户详情页的View就有可能包含用户基础信息表(用户名、用户ID、用户Token等)、以及用户详细信息表(用户邮箱地址、用户手机号等)。这就是一对多一个对象包含了多个表的数据的意思。

后者强调的是展示。举个例子,数据库中有三个表分别是:


二手房新房租房,它们三者的数据分别存储在三个表里面,这其实是一种横切


横切也是一种数据库的优化手段,横切与纵切不同的地方在于,横切是在保留了这套数据的完整性的前提下进行的切分,横切的结果就是把一个原本数据量很大的表,分成了好几个数据量不那么大的表。也就是原来三种房子都能用同一个表来存储,但是这样数据量就太大了,数据库响应速度就会下降。所以根据房子的类型拆成这三张表。横切也有根据ID切的,比如根据ID取余的结果来决定分在哪些表里,这种做法比较广泛,因为拓展起来方便,到时候数据表又大了,大不了除数也跟着再换一个更大的数罢了。其实根据类型去横切也可以,只是拓展的时候就不那么方便。


刚才扯远了现在我再扯回来,这三张表在展示的时候,只是根据类型的不同,界面才有稍许不同而已,所以还是会用同一张View去展示这三种数据,这就是一对多一个对象用于展示多种表的数据的意思。




一个对象包含了多个表的数据时,如何进行存取操作?



在进行取操作时,其实跟前面多对一的取操作是一样的,用Merge操作就可以了。


RecordViewA *a;

a = [self.CasaTable findLatestRecordWithError:NULL];
[a mergeRecord:[self.TaloyumTable findLatestRecordWithError:NULL] shouldOverride:YES];
[a mergeRecord:[self.CasatwyTable findLatestRecordWithError:NULL] shouldOverride:YES];

return a;


在进行存操作时,Virtual Record<CTPersistanceRecordProtocol>要求实现者实现- (NSDictionary *)dictionaryRepresentationWithColumnInfo:(NSDictionary *)columnInfo tableName:(NSString *)tableName;这个方法,实现者可以根据传入的columnInfotableName返回相应的数据,这样就能够把这一次存数据时关心的内容提供给持久层了。代码样例就是这样的:


RecordViewA *a = ...... ;

/*
由于有- (NSDictionary *)dictionaryRepresentationWithColumnInfo:(NSDictionary *)columnInfo tableName:(NSString *)tableName;的实现,a对象自己会提供给不同的Table它们感兴趣的内容而存储。

所以直接存就好了。
*/

[self.CasaTable insertRecord:a error:NULL];
[self.TaloyumTable insertRecord:a error:NULL];
[self.CasatwyTable insertRecord:a error:NULL];


通过上面的存取案例,你会发现使用Virtual Record之后,代码量一下子少掉很多,原本那些乱七八糟用于拼凑条件的代码全部被分散进了各个虚拟记录的实现中去了,代码维护因此就变得相当方便。若是采用传统做法,再存取之前少不了要写一大段逻辑,如果涉及代码迁移,这大段逻辑就也得要跟着迁移过去,这就很蛋疼了。




一个对象用于展示多种表的数据,如何进行存取操作?



在这种情况下的存操作其实跟上面一样,直接存。Virtual Record的实现者自己会根据要存入的表的信息组装好数据提供给持久层。样例代码与上一小节的存操作中给出的一模一样,我就不复制粘贴了。

取操作就不太一样了,不过由于取出时的对象是唯一的(因为一对多嘛),代码也一样十分简单:


ViewRecord *a;
ViewRecord *b;
ViewRecord *c;

self.itemATable.recordClass = [ViewRecord class];
self.itemBTable.recordClass = [ViewRecord class];
self.itemCTable.recordClass = [ViewRecord class];

[a = self.itemATable findLatestRecordWithError:NULL];
[b = self.itemBTable findLatestRecordWithError:NULL];
[c = self.itemCTable findLatestRecordWithError:NULL];


这里的abc都是同一个View,然后itemATableitemBTableitemCTable分别是不同种类的表。这个例子表示了一个对象如何用于展示不同类型的数据。如果使用传统方法,这里少不了要写很多适配代码,但是使用Virtual Record之后,这些代码都由各自实现者消化掉了,在执行数据逻辑时可以无需关心适配逻辑。




多对多场景?



其实多对多场景就是上述这些一对多多对一场景的排列组合,实现方式都是一模一样的,我这里就也不多啰嗦了。




交互方案的总结



在交互方案的设计中,架构师应当区分好强弱业务,把传统的Data Model区分成TableRecord,并由DataCenter去实现强业务,Table去实现弱业务。在这里由于DataCenter是强业务相关,所以在实际编码中,业务工程师负责创建DataCenter,并向业务层提供业务友好的方法,然后再在DataCenter中操作Table来完成业务层交付的需求。区分强弱业务,将TableRecord拆分开的好处在于:


  1. 通过业务细分降低耦合度,使得代码迁移和维护非常方便
  2. 通过拆解数据处理逻辑和数据表达形态,使得代码具有非常良好的可拓展性
  3. 做到读写隔离,避免业务层的误操作引入Bug
  4. 为Virtual Record这一设计思路的实践提供基础,进而实现更灵活,对业务更加友好的架构


任何不区分强弱业务的架构都是架构师在耍流氓,嗯。

在具体与业务层交互时,采用Virtual Record的设计思路来设计Record,由具体的业务对象来实现Virtual Record,并以它作为DataCenter和业务层之间的数据媒介进行交互。而不是使用传统的数据模型来与业务层做交互。

使用Virtual Record的好处在于:


  1. 将数据适配和数据转化逻辑封装到具体的Record实现中,可以使得代码更加抽象简洁,代码污染更少
  2. 数据迁移时只需要迁移Virtual Record相关方法即可,非常容易拆分
  3. 业务工程师实现业务逻辑时,可以在不损失可维护性的前提下,极大提高业务实现的灵活性


这一部分还顺便提了一下横切纵切的概念。本来是打算有一小节专门写数据库性能优化的,不过事实上移动App场景下数据库的性能优化手段不像服务端那样丰富多彩,很多牛逼技术和参数调优手段想用也用不了。差不多就只剩下数据切片的手段比较有效了,所以性能优化这块感觉没什么好写的。其实大家了解了切片的方式和场景,就足以根据自己的业务场景去做优化了。再使用一下Instrument的Time Profile再配合SQLite提供的一些函数,就足以找到慢在哪儿,然后去做性能调优了。但如果我把这些也写出来,就变成教你怎么使用工具,感觉这个太low写着也不起劲,大家有兴趣搜使用手册下来看就行。




数据库版本迁移方案



一般来说,具有持久层的App同时都会附带着有版本迁移的需求。当一个用户安装了旧版本的App,此时更新App之后,若数据库的表结构需要更新,或者数据本身需要批量地进行更新,此时就需要有版本迁移机制来进行这些操作。然而版本迁移机制又要兼顾跨版本的迁移需求,所以基本上大方案也就只有一种:建立数据库版本节点,迁移的时候一个一个跑过去。

数据迁移事实上实现起来还是比较简单的,做好以下几点问题就不大了:


  1. 根据应用的版本记录每一版数据库的改变,并将这些改变封装成对象
  2. 记录好当前数据库的版本,便于跟迁移记录做比对
  3. 在启动数据库时执行迁移操作,如果迁移失败,提供一些降级方案


CTPersistance在数据迁移方面,凡是对于数据库原本没有的数据表,如果要新增,在使用table的时候就会自动创建。因此对于业务工程师来说,根本不需要额外多做什么事情,直接用就可以了。把这部分工作放到这里,也是为数据库版本迁移节省了一些步骤。


CTPersistance也提供了Migrator。业务工程师可以自己针对某一个数据库编写一个Migrator。这个Migrator务必派生自CTPersistanceMigrator,且符合<CTPersistanceMigratorProtocol>,只要提供一个migrationStep的字典,以及记录版本顺序的数组。然后把你自己派生的Migrator的类名和对应关心的数据库名写在CTPersistanceConfiguration.plist里面就可以。CTPersistance会在初始数据库的时候,根据plist里面的配置对应找到Migrator,并执行数据库版本迁移的逻辑。


在版本迁移时要注意的一点是性能问题。我们一般都不会在主线程做版本迁移的事情,这自然不必说。需要强调的是,SQLite本身是一个容错性非常强的数据库引擎,因此差不多在执行每一个SQL的时候,内部都是走的一个Transaction。当某一版的SQL数量特别多的时候,建议在版本迁移的方法里面自己建立一个Transaction,然后把相关的SQL都包起来,这样SQLite执行这些SQL的时候速度就会快一点。

其他的似乎并没有什么要额外强调的了,如果有没说到的地方,大家可以在评论区提出来。




数据同步方案



数据同步方案大致分两种类型,一种类型是单向数据同步,另一种类型是双向数据同步。下面我会分别说说这两种类型的数据同步方案的设计。




单向数据同步



单向数据同步就是只把本地较新数据的操作同步到服务器,不会从服务器主动拉取同步操作。


比如即时通讯应用,一个设备在发出消息之后,需要等待服务器的返回去知道这个消息是否发送成功,是否取消成功,是否删除成功。然后数据库中记录的数据就会随着这些操作是否成功而改变状态。但是如果换一台设备继续执行操作,在这个新设备上只会拉取旧的数据,比如聊天记录这种。但对于旧的数据并没有删除或修改的需求,因此新设备也不会问服务器索取数据同步的操作,所以称之为单向数据同步。


单向数据同步一般来说也不需要有job去做定时更新的事情。如果一个操作迟迟没有收到服务器的确认,那么在应用这边就可以认为这个操作失败,然后一般都是在界面上把这些失败的操作展示出来,然后让用户去勾选需要重试的操作,然后再重新发起请求。微信在消息发送失败的时候,就是消息前面有个红色的圈圈,里面有个感叹号,只有用户点击这个感叹号的时候才重新发送消息,背后不会有个job一直一直跑。


所以细化需求之后,我们发现单向数据同步只需要做到能够同步数据的状态即可。




如何完成单向数据同步的需求



添加identifier



添加identifier的目的主要是为了解决客户端数据的主键和服务端数据的主键不一致的问题。由于是单向数据同步,所以数据的生产者只会是当前设备,那么identifier也理所应当由设备生成。当设备发起同步请求的时候,把identifier带上,当服务器完成任务返回数据时,也把这些identifier带上。然后客户端再根据服务端给到的identifier再更新本地数据的状态。identifier一般都会采用UUID字符串。




添加isDirty



isDirty主要是针对数据的插入和修改进行标识。当本地新生成数据或者更新数据之后,收到服务器的确认返回之前,isDirty置为YES。当服务器的确认包返回之后,再根据包里提供的identifier找到这条数据,然后置为NO。这样就完成了数据的同步。

然而这只是简单的场景,有一种比较极端的情况在于,当请求发起到收到请求回复的这短短几秒间,用户又修改了数据。如果按照当前的逻辑,在收到请求回复之后,这个又修改了的数据的isDirty会被置为NO,于是这个新的修改就永远无法同步到服务器了。这种极端情况的简单处理方案就是在发起请求到收到回复期间,界面上不允许用户进行修改。

如果希望做得比较细致,在发送同步请求期间依旧允许用户修改的话,就需要在数据库额外增加一张DirtyList来记录这些操作,这个表里至少要有两个字段:identifierprimaryKey。然后每一次操作都分配一次identifier,那么新的修改操作就有了新的identifier。在进行同步时,根据primaryKey找到原数据表里的那条记录,然后把数据连同identifier交给服务器。然后在服务器的确认包回来之后,就只要拿出identifier再把这条操作记录删掉即可。这个表也可以直接服务于多个表,只是还需要额外添加一个tablename字段,方便发起同步请求的时候能够找得到数据。




添加isDeleted



当有数据同步的需求的时候,删除操作就不能是简单的物理删除了,而只是逻辑删除,所谓逻辑删除就是在数据库里把这条记录的isDeleted记为YES,只有当服务器的确认包返回之后,才会真正把这条记录删除。isDeleted和isDirty的区别在于:收到确认包后,返回的identifier指向的数据如果是isDeleted,那么就要删除这条数据,如果指向的数据只是新插入的数据和更新的数据,那么就只要修改状态就行。插入数据和更新数据在收到数据包之后做的操作是相同的,所以就用isDirty来区分就足够了。总之,这是根据收到确认包之后的操作不同而做的区分。两者都要有,缺一不可。




在请求的数据包中,添加dependencyIdentifier



在我看到的很多其它数据同步方案中,并没有提供dependencyIdentifier,这会导致一个这样的问题:假设有两次数据同步请求一起发出,A先发,B后发。结果反而是B请求先到,A请求后到。如果A请求的一系列同步操作里面包含了插入某个对象的操作,B请求的一系列同步操作里面正好又删除了这个对象,那么由于到达次序的先后问题错乱,就导致这个数据没办法删除。

这个在移动设备的使用场景下是很容易发生的,移动设备本身网络环境就多变,先发的包反而后到,这种情况出现的几率还是比较大的。所以在请求的数据包中,我们要带上上一次请求时一系列identifier的其中一个,就可以了。一般都是选择上次请求里面最后的那一个操作的identifier,这样就能表征上一次请求的操作了。

服务端这边也要记录最近的100个请求包里面的最后一个identifier。之所以是100条纯属只是拍脑袋定的数字,我觉得100条差不多就够了,客户端发请求的时候denpendency应该不会涉及到前面100个包。服务端在收到同步请求包的时候,先看denpendencyIdentifier是否已被记录,如果已经被记录了,那么就执行这个包里面的操作。如果没有被记录,那就先放着再等等,等到条件满足了再执行,这样就能解决这样的问题。

之所以不用更新时间而是identifier来做标识,是因为如果要用时间做标识的话,就是只能以客户端发出数据包时候的时间为准。但有时不同设备的时间不一定完全对得上,多少会差个几秒几毫秒,另外如果同时有两个设备发起同步请求,这两个包的时间就都是一样的了。假设A1, B1是1号设备发送的请求,A2, B2,是2号设备发送的请求,如果用时间去区分,A1到了之后,B2说不定就直接能够执行了,而A1还没到服务器呢。

当然,这也是一种极端情况,用时间的话,服务器就只要记录一个时间了,凡是依赖时间大于这个时间的,就都要再等等,实现起来就比较方便。但是为了保证bug尽可能少,我认为依赖还是以identifier为准,这要比以时间为准更好,而且实现起来其实也并没有增加太多复杂度。




单向数据同步方案总结



  1. 改造的时候添加identifier,isDirty,isDeleted字段。如果在请求期间依旧允许对数据做操作,那么就要把identifier和primaryKey再放到一个新的表中
  2. 每次生成数据之后对应生成一个identifier,然后只要是针对数据的操作,就修改一次isDirty或isDeleted,然后发起请求带上identifier和操作指令去告知服务器执行相关的操作。如果是复杂的同步方式,那么每一次修改数据时就新生成一次identifier,然后再发起请求带上相关数据告知服务器。
  3. 服务器根据请求包的identifier等数据执行操作,操作完毕回复给客户端确认
  4. 收到服务器的确认包之后,根据服务器给到的identifier(有的时候也会有tablename,取决于你的具体实现)找到对应的记录,如果是删除操作,直接把数据删除就好。如果是插入和更新操作,就把isDirty置为NO。如果有额外的表记录了更新操作,直接把identifier对应的这个操作记录删掉就行。




要注意的点



在使用表去记录更新操作的时候,短时间之内很有可能针对同一条数据进行多次更新操作。因此在同步之前,最好能够合并这些相同数据的更新操作,可以节约服务器的计算资源。当然如果你服务器强大到不行,那就无所谓了。




双向数据同步



双向数据同步多见于笔记类、日程类应用。对于一台设备来说,不光自己会往上推数据同步的信息,自己也会问服务器主动索取数据同步的信息,所以称之为双向数据同步。

举个例子:当一台设备生成了某时间段的数据之后,到了另外一台设备上,又修改了这些旧的历史数据。此时再回到原来的设备上,这台设备就需要主动问服务器索取是否旧的数据有修改,如果有,就要把这些操作下载下来同步到本地。

双向数据同步实现上会比单向数据同步要复杂一些,而且有的时候还会存在实时同步的需求,比如协同编辑。由于本身方案就比较复杂,另外一定要兼顾业务工程师的上手难度(这主要看你这个架构师的良心),所以要实现双向数据同步方案的话,还是很有意思比较有挑战的。




如何完成双向数据同步的需求



封装操作对象



这个其实在单向数据同步时多少也涉及了一点,但是由于单向数据同步的要求并不复杂,只要告诉服务器是什么数据然后要做什么事情就可以了,倒是没必要将这种操作封装。在双向数据同步时,你也得解析数据操作,所以互相之间要约定一个协议,通过封装这个协议,就做到了针对操作对象的封装。

这个协议应当包括:


  1. 操作的唯一标识
  2. 数据的唯一标识
  3. 操作的类型
  4. 具体的数据,主要是在Insert和Update的时候会用到
  5. 操作的依赖标识
  6. 用户执行这项操作时的时间戳


分别解释一下这6项的意义:




  1. 操作的唯一标识


这个跟单向同步方案时的作用一样,也是在收到服务器的确认包之后,能够使得本地应用找到对应的操作并执行确认处理。



  1. 数据的唯一标识


在找到具体操作的时候执行确认逻辑的处理时,都会涉及到对象本身的处理,更新也好删除也好,都要在本地数据库有所体现。所以这个标识就是用于找到对应数据的。



  1. 操作的类型


操作的类型就是DeleteUpdateInsert,对应不同的操作类型,对本地数据库执行的操作也会不一样,所以用它来进行标识。



  1. 具体的数据


当更新的时候有Update或者Insert操作的时候,就需要有具体的数据参与了。这里的数据有的时候不见得是单条的数据内容,有的时候也会是批量的数据。比如把所有10月1日之前的任务都标记为已完成状态。因此这里具体的数据如何表达,也需要定一个协议,什么时候作为单条数据的内容去执行插入或更新操作,什么时候作为批量的更新去操作,这个自己根据实际业务需求去定义就行。



  1. 操作的依赖标识


跟前面提到的依赖标识一样,是为了防止先发的包后到后发的包先到这种极端情况。



  1. 用户执行这项操作的时间戳


由于跨设备,又因为旧数据也会被更新,因此在一定程度上就会出现冲突的可能。操作数据在从服务器同步下来之后,会存放在一个新的表中,这个表就是待操作数据表,在具体执行这些操作的同时会跟待同步的数据表中的操作数据做比对。如果是针对同一条数据的操作,且这两个操作存在冲突,那么就以时间戳来决定如何执行。还有一种做法就是直接提交到界面告知用户,让用户做决定。




新增待操作数据表和待同步数据表



前面已经部分提到这一点了。从服务器拉下来的同步操作列表,我们存在待执行数据表中,操作完毕之后如果有告知服务器的需求,那就等于是走单向同步方案告知服务器。在执行过程中,这些操作也要跟待同步数据表进行匹配,看有没有冲突,没有冲突就继续执行,有冲突的话要么按照时间戳执行,要么就告知用户让用户做决定。在拉取待执行操作列表的时候,也要把最后一次操作的identifier丢给服务器,这样服务器才能返回相应数据。

待同步数据表的作用其实也跟单向同步方案时候的作用类似,就是防止在发送请求的时候用户有操作,同时也是为解决冲突提供方便。在发起同步请求之前,我们都应该先去查询有没有待执行的列表,当待执行的操作列表同步完成之后,就可以删除里面的记录了,然后再把本地待同步的数据交给服务器。同步完成之后就可以把这些数据删掉了。因此在正常情况下,只有在待操作待执行的操作间会存在冲突。有些从道理上讲也算是冲突的事情,比如获取待执行的数据比较晚,但其中又和待同步中的操作有冲突,像这种极端情况我们其实也无解,只能由他去,不过这种情况也是属于比较极端的情况,发生几率不大。




何时从服务器拉取待执行列表



  1. 每次要把本地数据丢到服务器去同步之前,都要拉取一次待执行列表,执行完毕之后再上传本地同步数据
  2. 每次进入相关页面的时候都更新一次,看有没有新的操作
  3. 对实时性要求比较高的,要么客户端本地起一个线程做轮询,要么服务器通过长链接将待执行操作推送过来
  4. 其它我暂时也想不到了,具体还是看需求吧




双向数据同步方案总结



  1. 设计好同步协议,用于和服务端进行交互,以及指导本地去执行同步下来的操作
  2. 添加待执行待同步数据表记录要执行的操作和要同步的操作




要注意的点



我也见过有的方案是直接把SQL丢出去进行同步的,我不建议这么做。最好还是将操作和数据分开,然后细化,否则检测冲突的时候你就得去分析SQL了。要是这种实现中有什么bug,解这种bug的时候就要考虑前后兼容问题,机制重建成本等,因为贪图一时偷懒,到最后其实得不偿失。




总结



这篇文章主要是基于CTPersistance讲了一下如何设计持久层的设计方案,以及数据迁移方案和数据同步方案。

着重强调了一下各种持久层方案在设计时要考虑的隔离,以及提出了Virtual Record这个设计思路,并对它做了一些解释。然后在数据迁移方案设计时要考虑的一些点。在数据同步方案这一节,分开讲了单向的数据同步方案和双向的数据同步方案的设计,然而具体实现还是要依照具体的业务需求来权衡。

希望大家觉得这些内容对各自工作中遇到的问题能够有所价值,如果有问题,欢迎在评论区讨论。

另外,关于动态部署方案,其实直到今天在iOS领域也并没有特别好的动态部署方案可以拿出来,我觉得最靠谱的其实还是H5和Native的Hybrid方案。React Native在我看来相比于Hybrid还是有比较多的限制。关于Hybrid方案,我也提供了CTJSBridge这个库去实现这方面的需求。在动态部署方案这边其实成文已经很久,迟迟不发的原因还是因为觉得当时并没有什么银弹可以解决iOS App的动态部署,另外也有一些问题没有考虑清楚。当初想到的那些问题现在我已经确认无解。当初写的动态部署方案我一直认为它无法作为一个单独的文章发布出来,所以我就把这篇文章也放在这里,权当给各位参考。








iOS动态部署方案



前言


这里讨论的动态部署方案,就是指通过不发版的方式,将新的内容、新的业务流程部署进已发布的App。因为苹果的审核周期比较长,而且苹果的限制比较多,业界在这里也没有特别多的手段来达到动态部署方案的目的。这篇文章主要的目的就是给大家列举一下目前业界做动态部署的手段,以及其对应的优缺点。然后给出一套我比较倾向于使用的方案。

其实单纯就动态部署方案来讲,没什么太多花头可以说的,就是H5、Lua、JS、OC/Swift这几门基本技术的各种组合排列。写到后面觉得,动态部署方案其实是非常好的用于讲解某些架构模式的背景。一般我们经验总结下来的架构模式包括但不限于:


  1. Layered Architecture
  2. Event-Driven Architecture
  3. Microkernel Architecture
  4. Microservices Architecture
  5. Space-Based Architecture


我在开篇里面提到的MVC等方案跟这篇文章中要提到的架构模式并不是属于同一个维度的。比较容易混淆的就是容易把MVC这些方案跟Layered Architecture混淆,这个我在开篇这篇文章里面也做过了区分:MVC等方案比较侧重于数据流动方向的控制和数据流的管理。Layered Architecture更加侧重于各分层之间的功能划分和模块协作。

另外,上述五种架构模式在Software Architecture Patterns这本书里有非常详细的介绍,整本书才45页,个把小时就看完了,非常值得看和思考。本文后半篇涉及的架构模式是以上架构模式的其中两种:Microkernel ArchitectureMicroservices Architecture

最后,文末还给出了其他一些关于架构模式的我觉得还不错的PPT和论文,里面对架构模式的分类和总结也比较多样,跟Software Architecture Patterns的总结也有些许不一样的地方,可以博采众长。




Web App


实现方案


其实所谓的web app,就是通过手机上的浏览器进行访问的H5页面。这个H5页面是针对移动场景特别优化的,比如UI交互等。



优点


  1. 无需走苹果流程,所有苹果流程带来的成本都能避免,包括审核周期、证书成本等。
  2. 版本更新跟网页一样,随时生效。
  3. 不需要Native App工程师的参与,而且市面上已经有很多针对这种场景的框架。



缺点


  1. 由于每一页都需要从服务器下载,因此web app重度依赖网络环境。
  2. 同样的UI效果使用web app来实现的话,流畅度不如Native,比较影响用户体验。
  3. 本地持久化的部分很难做好,绕过本地持久化的部分的办法就是提供账户体系,对应账户的持久化数据全部存在服务端。
  4. 即时响应方案、远程通知实现方案、移动端传感器的使用方案复杂,维护难度大。
  5. 安全问题,H5页面等于是所有东西都暴露给了用户,如果对安全要求比较高的,很多额外的安全机制都需要在服务端实现。



总结

web app一般是创业初期会重点考虑的方案,因为迭代非常快,而且创业初期的主要目标是需要验证模式的正确性,并不在于提供非常好的用户体验,只需要完成闭环即可。早年facebook曾经尝试过这种方案,最后因为用户体验的问题而宣布放弃。所以这个方案只能作为过渡方案,或者当App不可用时,作为降级方案使用。




Hybrid App


通过市面上各种Hybrid框架,来做H5和Native的混合应用,或者通过JS Bridge来做到H5和Native之间的数据互通。



优点


  1. 除了要承担苹果流程导致的成本以外,具备所有web app的优势
  2. 能够访问本地数据、设备传感器等



缺点


  1. 跟web app一样存在过度依赖网络环境的问题
  2. 用户体验也很难做到很好
  3. 安全性问题依旧存在
  4. 大规模的数据交互很难实现,例如图片在本地处理后,将图片传递给H5



总结

Hybrid方案更加适合跟本地资源交互不是很多,然后主要以内容展示为主的App。在天猫App中,大量地采用了JS Bridge的方式来让H5跟Native做交互,因为天猫App是一个以内容展示为主的App,且营销活动多,周期短,比较适合Hybrid。




React-Native


严格来说,React-Native应当放到Hybrid那一节去讲,单独拎出来的原因是Facebook自从放出React-Native之后,业界讨论得非常激烈。天猫的鬼道也做了非常多的关于React-Native的分享。

React-Native这个框架比较特殊,它展示View的方式依然是Native的View,然后也是可以通过URL的方式来动态生成View。而且,React-Native也提供了一个Bridge通道来做Javascript和Objective-C之间的交流,还是很贴心的。

然而研究了一下发现有一个比较坑的地方在于,解析JS要生成View时所需要的View,是要本地能够提供的。举个例子,比如你要有一个特定的Mapview,并且要响应对应的delegate方法,在React-Native的环境下,你需要先在Native提供这个Mapview,并且自己实现这些delegate方法,在实现完方法之后通过Bridge把数据回传给JS端,然后重新渲染。

在这种情况下我们就能发现,其实React-Native在使用View的时候,这些View是要经过本地定制的,并且将相关方法通过RCT_EXPORT_METHOD暴露给js,js端才能正常使用。在我看来,这里在一定程度上限制了动态部署时的灵活性,比如我们需要在某个点击事件中展示一个动画或者一个全新的view,由于本地没有实现这个事件或没有这个view,React-Native就显得捉襟见肘。



优点


  1. 响应速度很快,只比Native慢一点,比webview快很多。
  2. 能够做到一定程度上的动态部署



缺点


  1. 组装页面的元素需要Native提供支持,一定程度上限制了动态部署的灵活性。



总结


由于React-Native框架中,因为View的展示和View的事件响应分属于不同的端,展示部分的描述在JS端,响应事件的监听和描述都在Native端,通过Native转发给JS端。所以,从做动态部署的角度上讲,React-Native只能动态部署新View,不能动态部署新View对应的事件。当然,React-Native本身提供了很多基础组件,然而这个问题仍然还是会限制动态部署的灵活性。因为我们在动态部署的时候,大部分情况下是希望View和事件响应一起改变的。

另外一个问题就在于,View的原型需要从Native中取,这个问题相较于上面一个问题倒是显得不那么严重,只是以后某个页面需要添加某个复杂的view的时候,需要从现有的组件中拼装罢了。

所以,React-Native事实上解决的是如何不使用Objc/Swift来写iOS App的View的问题,对于如何通过不发版来给已发版的App更新功能这样的问题,帮助有限。




Lua Patch


大众点评的屠毅敏同学在基于wax的基础上写了waxPatch,这个工具的主要原理是通过lua来针对objc的方法进行替换,由于lua本身是解释型语言,可以通过动态下载得到,因此具备了一定的动态部署能力。然而iOS系统原生并不提供lua的解释库,所以需要在打包时把lua的解释库编译进app。



优点


  1. 能够通过下载脚本替换方法的方式,修改本地App的行为。
  2. 执行效率较高



缺点


  1. 对于替换功能来说,lua是很不错的选择。但如果要添加新内容,实际操作会很复杂
  2. 很容易改错,小问题变成大问题



总结

lua的解决方案在一定程度上解决了动态部署的问题。实际操作时,一般不使用它来做新功能的动态部署,主要还是用于修复bug时代码的动态部署。实际操作时需要注意的另外一点是,真的很容易改错,尤其是你那个方法特别长的时候,所以改了之后要彻底回归测试一次。




Javascript Patch


这个工作原理其实跟上面说的lua那套方案的工作原理一样,只不过是用javascript实现。而且最近新出了一个JSPatch这个库,相当好用。



优点


  1. 同Lua方案的优点
  2. 打包时不用将解释器也编译进去,iOS自带JavaScript的解释器,只不过要从iOS7.0以后才支持。



缺点


  1. 同Lua方案的缺点



总结


在对app打补丁的方案中,目前我更倾向于使用JSPatch的方案,在能够完成Lua做到的所有事情的同时,还不用编一个JS解释器进去,而且会javascript的人比会lua的人多,技术储备比较好做。




JSON Descripted View


其实这个方案的原理是这样的:使用JSON来描述一个View应该有哪些元素,以及元素的位置,以及相关的属性,比如背景色,圆角等等。然后本地有一个解释器来把JSON描述的View生成出来。

这跟React-Native有点儿像,一个是JS转Native,一个是JSON转Native。但是同样有的问题就是事件处理的问题,在事件处理上,React-Native做得相对更好。因为JSON不能够描述事件逻辑,所以JSON生成的View所需要的事件处理都必须要本地事先挂好。



优点


  1. 能够自由生成View并动态部署



缺点


  1. 天猫实际使用下来,发现还是存在一定的性能问题,不够快
  2. 事件需要本地事先写好,无法动态部署事件



总结


其实JSON描述的View比React-Native的View有个好处就在于对于这个View而言,不需要本地也有一套对应的View,它可以依据JSON的描述来自己生成。然而对于事件的处理是它的硬伤,所以JSON描述View的方案,一般比较适用于换肤,或者固定事件不同样式的View,比如贴纸。




架构模式


其实我们要做到动态部署,至少要满足以下需求:

  1. View和事件都要能够动态部署
  2. 功能完整
  3. 便于维护


我更加倾向于H5和Native以JSBridge的方式连接的方案进行动态部署,在cocoapods里面也有蛮多的JSBridge了。看了一圈之后,我还是选择写了一个CTJSBridge,来满足动态部署和后续维护的需求。关于这个JSBridge的使用中的任何问题和需求,都可以在评论区向我提出来。接下来的内容,会主要讨论以下这些问题:

  1. 为什么不是React-Native或其它方案?
  2. 采用什么样的架构模式才是使用JSBridge的最佳实践?



为什么不是React-Native或其他方案?


首先针对React-Native来做解释,前面已经分析到,React-Native有一个比较大的局限在于View需要本地提供。假设有一个页面的组件是跑马灯,如果本地没有对应的View,使用React-Native就显得很麻烦。然而同样的情况下,HTML5能够很好地实现这样的需求。这里存在一个这样的取舍在性能和动态部署View及事件之间,选择哪一个?

我更加倾向于能够动态部署View和事件,至少后者是能够完成需求的,性能再好,难以完成需求其实没什么意义。然而对于HTML5的Hybrid和纯HTML5的web app之间,也存在一个相同的取舍,但是还要额外考虑一个新的问题,纯HTML5能够使用到的设备提供的功能相对有限,JSBridge能够将部分设备的功能以Native API的方式交付给页面,因此在考虑这个问题之后,选择HTML5的Hybrid方案就显得理所应当了。

在诸多Hybrid方案中,除了JSBridge之外,其它的方案都显得相对过于沉重,对于动态部署来说,其实需要补充的软肋就是提供本地设备的功能,其它的反而显得较为累赘。



基于JSBridge的微服务架构模式


我开发了一个,基于JSBridge的微服务架构差不多是这样的:


                                 -------------------------
                                 |                       |
                                 |         HTML5         |
                                 |                       |
                                 | View + Event Response |
                                 |                       |
                                 -------------------------
                                             |
                                             |
                                             |
                                          JSBridge
                                             |
                                             |
                                             |
        ------------------------------------------------------------------------------
        |                                                                            |
        |   Native                                                                   |
        |                                                                            |
        |  ------------   ------------   ------------   ------------   ------------  |
        |  |          |   |          |   |          |   |          |   |          |  |
        |  | Service1 |   | Service2 |   | Service3 |   | Service4 |   |    ...   |  |
        |  |          |   |          |   |          |   |          |   |          |  |
        |  ------------   ------------   ------------   ------------   ------------  |
        |                                                                            |
        |                                                                            |
        ------------------------------------------------------------------------------


解释一下这种架构背后的思想:

因为H5和Native之间能够通过JSBridge进行交互,然而JSBridge的一个特征是,只能H5主动发起调用。所以理所应当地,被调用者为调用者提供服务。

另外一个想要处理的问题是,希望能够通过微服务架构,来把H5和Native各自的问题域区分开。所谓区分问题域就是让H5要解决的问题和Native要解决的问题之间,交集最小。因此,我们设计时希望H5的问题域能够更加偏重业务,然后Native为H5的业务提供基础功能支持,例如API的跨域调用,传感器设备信息以及本地已经沉淀的业务模块都可以作为Native提供的服务交给H5去使用。H5的快速部署特性特别适合做重业务的事情,Native对iPhone的功能调用能力和控制能力特别适合将其封装成服务交给H5调用。

所以这对Native提供的服务有两点要求:


  1. Native提供的服务不应当是强业务相关的,最好是跟业务无关,这样才能方便H5进行业务的组装
  2. 如果Native一定要提供强业务相关的服务,那最好是一个完整业务,这样H5就能比较方便地调用业务模块。


只要Native提供的服务符合上述两个条件,HTML5在实现业务的时候,束缚就会非常少,也非常容易管理。




然后这种方案也会有一定的局限性,就是如果Native没有提供这样的服务,那还是必须得靠发版来解决。等于就是Native向HTML5提供API,这其实跟服务端向Native提供API的道理一样。

但基于Native提供的服务的通用性这点来看,添加服务的需求不会特别频繁,每一个App都有属于自己的业务领域,在同一个业务领域下,其实需要Native提供的服务是有限的。然后结合JSPatch提供的动态patch的能力,这样的架构能够满足绝大部分动态部署的需求。

然后随着App的不断迭代,某些HTML5的实现其实是可以逐步沉淀为Native实现的,这在一定程度上,降低了App早期的试错成本。




基于动态库的微内核模式


我开发了CTDynamicLibKit这个库来解决动态库的调用问题,其实原先的打算是拿动态库做动态部署的,不过我用@念纪 的个人App把这个功能塞进去之后,发现苹果还是能审核通过的,但是下载下来的动态库是无法加载的。报错如下:


error:Error Domain=NSCocoaErrorDomain Code=3587 "The bundle “DynamicLibDemo” couldn’t be loaded because it is damaged or missing necessary resources." (dlopen_preflight(/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo): no suitable image found.  Did find:
        /var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo: code signature invalid for '/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo'
    ) UserInfo=0x174260b80 {NSLocalizedFailureReason=The bundle is damaged or missing necessary resources., NSLocalizedRecoverySuggestion=Try reinstalling the bundle., NSFilePath=/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo, NSDebugDescription=dlopen_preflight(/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo): no suitable image found.  Did find:
        /var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo: code signature invalid for '/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo'
    , NSBundlePath=/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework, NSLocalizedDescription=The bundle DynamicLibDemo couldnt be loaded because it is damaged or missing necessary resources.}


主要原因是因为签名无法通过。因为Distribution的App只能加载相同证书打包的framework。在in house和develop模式下,可以使用相同证书既打包App又打包framework,所以测试的时候没有问题。但是在正式的distribution下,这种做法是行不通的。

所以就目前看来,基于动态库的动态部署方案是没办法做到的。




总结


我在文中针对业界常见的动态部署方案做了一些总结,并且提供了我自己认为的最佳解决方案以及对应的JSBridge实现。文中提到的方案我已经尽可能地做到了全面,如果还有什么我遗漏没写的,大家可以在评论区指出,我把它补上去。





有任何问题建议直接在评论区提问,这样后来的人如果有相同的问题,就能直接找到答案了。提问之前也可以先看看评论区有没有人问过类似问题了。

所有评论和问题我都会在第一时间回复,QQ上我是不回答问题的哈。




怎么面试架构师

作者 Casa Taloyum
2015年6月14日 00:00




其实本文想说的是:当面试一个架构师的时候,我们应该问什么问题?我觉得,问什么样的问题,体现了team leader更加看重架构师的哪些特点。

我一直认为,做技术就跟练武一样,在练武的不同阶段,分招式和心法。技术也一样,在不同的阶段,也分招式和心法。另外,就我个人而言,经常忘记招式,一方面可以说十二年来,我用过的招式很多,到了现在也不记得几个。另一方面我自己也不会特意去记。事实上,十二年代码写下来,我反而越来越不关注招式,而是越来越关注如何解决问题,也就是心法。所以我作为team leader的时候,我会更加看重这个架构师候选人是不是有一套属于自己的心法。

上面说的听着很玄,下面我就直接回到正题:我们面试架构师候选人时,应该问什么样的问题?




大致会有几种类型的问题:

  1. 当前技术领域中的一些技术细节
  2. 算法和数据结构
  3. 方案设计思路




当前技术领域的技术细节类问题

针对第一类问题,我认为是很有必要问的,架构师对技术细节的理解,是很能够影响他做架构时的设计思路的。毕竟每一个领域都有不同,了解不同领域的差异,以及特定领域的技术细节,很影响架构时的设计思路和实现手段。

然而,这并不是鼓励大家去挖出各种细节的问题,然后去考察架构师候选人,这里需要有一个度。举个例子:


你如何去把一个view的所有subview清空?


  1. 如果知道NSArray有makeObjectsPerformSelector这个方法的人,他们能够说出直接使用这个方法,然后在selector里面写removeFromSuperView的selector,就好了,而且很省事,一句话就搞定。

  2. 如果知道NSArray有enumerator方法的人,他们会说出使用这种方法枚举每一个subview,在block里把removeFromSuperView调用起来,也差不多两三行的事儿。

  3. 不知道NSArray有上面这些方法的人,他会说用for...in...的方法遍历,然后取到这每一个subview,让他们执行removeFromSuperView。可能要花费大概四五行。




这几种答案谁的更好?在我看来一样好。为什么?

因为这个问题其实考察的是这个人知不知道某个方法,当然你可以说他知道这个方法是因为他仔细看过文档或者头文件。但除了这个以外,这个问题对判断这个人是不是一个合格的架构师没有任何意义。架构师的任务在于使用合理的手段完成架构的任务,上面三种做法都是合理的手段,只不过是实现技巧上的不同而已。

这样的问题还可以拓展开来:你完全可以问一个架构师候选人某一个领域的这种类似问题,而恰好你比他熟悉,如果候选人答不上来,你会认为他可能在这方面花的时间还不够,这方面的理解不够深,导致减分。但如果答上来了,有可能加分有可能不加分。

然而,这一切并没有什么卵用。如果角色对调,让候选人来面试你,他完全可以问出各种这样类似的问题,一样让你抓耳挠腮百思不得其解。那么该如何考察一个架构师候选人对自己领域中技术细节的理解呢?我们来看下面这些问题:


你觉得block当初是为了解决什么样的问题而设计的?你如何区分何时使用block,何时不使用?
你觉得ReactiveCocoa当初是为了解决什么样的问题而设计的?你何时会考虑使用RAC,何时不用?
你觉得MVVM这样的思想是为了解决什么样的问题而产生的?


答案在本文不是重点,当然如果各位对答案感兴趣,可以在评论区问一下,我在评论区回答。在我遇到的各种面试官中,我从来没遇到过能问出这样类似问题的面试官 。我面试别人的时候,我问过这种比较侧重对某一项技术的理解的问题,有人能答好有人答不好,然后从招进来的人看,当初答好这种问题的人,后来都在团队中起到了顶梁柱的作用。答不好这样问题的人,但是他们因为知道很多技术细节,也还是招进来了,虽然也能很好地完成需求和任务,但是代码结构、设计思路都会有或多或少的缺陷,写出来的组件在使用上也会感觉怪怪。

所以,考察一个架构师候选人在某一领域的技术时,通用的技术细节的问题可以问一下,偏门的技术细节问出来就很没有意义。一个架构师最关键的是他对技术的理解深度,理解深刻的人,才能写出简单易用易拓展的架构。然后面试官需要区分好问题,有些问题是属于“知道、不知道”,有些问题是属于“理解、不理解”,对于面试一个高级工程师来说,可能会比较偏向前者,因为他需要知道足够多,然后完成需求的速度才快,不需要总是去Google。但对于面试一个架构师来说,其实大部分基础知识应该是已经具备了的,不至于写个TableView还要去翻Google。但在做SDK的时候,是会遇到一些偏门问题的,是需要去Google的。但架构师跟高级工程师的区别就在于,架构师知道该往哪个方向去Google,能够把握住问题的实质去解决问题。所以对于考察架构师而言,应该更加偏向后者,理解和不理解。

回想一下,其实有很多类似知道、不知道的问题,你是在code review中,其他人的博客中,文档中就能学到的。但是那些理解、不理解的问题,其实大部分都是你多年代码的经验思考出来的,即便你去看了博客看了文档,该不理解的还是不理解。而作为一名架构师,真正要考察的就是理解、不理解的问题。所以你明白,为什么当初那些技术细节答不上来的人,但是对技术理解很深刻的人能成为顶梁柱,成为架构师。而技术细节知道很多,但技术理解不深刻的人还是只能做高级工程师的原因了吧?



算法和数据结构类问题


第二类问题,算法和数据结构相关的问题。这种问题也是很需要问的,但似乎现在在社招的时候会问这种问题的面试官不太多,只有在面试比较初级的人或者应届生的时候才会拿来问。

我觉得面试官即便在面试架构师的时候,还是要问这样的问题的,只是要注意考察侧重点。一个架构师如果不了解数据结构和算法,那他真的很难做出靠谱的架构,毕竟很多SDK底下充斥着各种各样的数据结构,而且有经验的人都很清楚,对于一类数据而言,不同的结构设计或表达方式,很影响最终实现的方案的优雅程度。所以我们面试架构师时,侧重点在于,对于某个问题,你如何去选择合适的数据结构,合适的算法来解决这样的问题。

但是,在面试应届生时,我们问算法和数据结构问题时,其实更加关注的是他的动手能力,给一个很简单的问题,然后让他把代码写出来,或白板,或IDE。就国内大部分公司招聘的情况和其公司自身的情况来看,如果你学facebook/google那样出算法题,你基本上招不到人。因为会这些题目的人,都在facebook/google那儿排队呢。

然后算法和数据结构相关的问题第二个考察点在于,候选人的思考是否足够细密。这个不管是对架构师候选人,还是对应届生还是对社招的高级工程师而言,重要程度都是一样的。这个就不多说了。

你让一名架构师候选人在面试的时候做一个华容道算法,在你而言其实是对他的一种鄙视,在他而言他也很有可能写不出。但如果你让一名架构师候选人在面试时候展示他对各数据结构的理解,不同场景下如何设计合理的数据结构和算法,如何权衡时间与空间的取舍,这才是对他的一种重视。



方案设计思路类问题


第三类问题,方案设计思路。大概一年以前我在面试携程的时候,遇到过面试官问我这种问题,其它我就没有遇到过了,一般都是我在自我介绍的时候主动挑一个去讲。我在面试别人的时候,我也会问这样的问题,比如说:


对于一个app的网络层,你在设计时,你会考虑哪些问题?
对于一个app的持久层,如果让你直接用sqlite,你如何设计版本迁移方案?
工作中,你会采用哪些手段来做解耦?

严格来说,大部分面试官也会问这样的问题,但是是看到你简历上写过你有这个经验,然后直接问这个方案你是怎么做的,而不是问这个方案你是怎么设计的。在我看来,大部分方案的实现其实没有什么技术含量,真正有技术含量的地方在于,拿到这个问题时,你是如何思考的。就比如数据库版本迁移方案,设计的过程是很艰苦的,但设计完毕实现的时候,就是码代码,不能说完全没有技术含量,只能说实现的时候所需要耗费的脑力跟设计时候比,差太远了,在我看来属于没有什么技术含量。

说到技术含量的事情,我也遇到过特别多的面试官喜欢问这个问题:过去你解决了哪些比较有技术含量的问题?我一般不会拿这个问题去问候选人,因为我觉得真的到了代码层面,是基本上不存在技术含量的概念的,码代码这个工作本身,就是用计算机能懂的方式告诉计算机应该怎么做事,其实就是一件很没技术含量的事情。

所以我认为的技术含量是,你如何去设计一个靠谱的解决方案,这个解决方案足够周密,思考足够长远,提供的API很好看,代码很容易阅读,很好维护。

还有就是逃不掉的23种设计模式。设计模式这种东西早年被业界说了很多,都说烂了,但我不否认的是,这种对设计方法的总结,是每个架构师的起步和入门。如果一个架构师连什么场合使用设么设计模式都分不清楚,各种设计模式他的设计初衷和希望解决的问题都不知道,那他算是不合格的架构师。然而面试官也很少会去问这样的问题,一方面可能觉得问这种问题很low,另一方面其实也有少部分面试官对设计模式仅仅处在了解和知道的情况,不敢随便拿出来问。



总结


面试架构师其实是一件不容易的事情,能考察架构师候选人实力的面试官,首先自己就已经对架构本身有了很好的理解,就应该是一个合格的架构师,其次是需要足够务实,有合理的手段合理的问题,通过面试来了解候选人是不是一个适合做架构师的人。最后,要有足够识人的眼光以及合适的判断标准,通过候选人的回答,对候选人进行筛选。从我对目前面试的情况来看,对这个我持悲观态度。大部分面试官给候选人的感觉更多的是:我问你一个这个问题,看你知不知道?而不是:我问你一个这个问题,看你怎么去思考?

架构师和更高级的高级工程师之间,还是有区别的。所以各家公司如果要想找到合理靠谱的架构师,还是很不容易的。




iOS应用架构谈 网络层设计方案

作者 Casa Taloyum
2015年6月1日 00:00




iOS应用架构谈 开篇
iOS应用架构谈 view层的组织和调用方案
iOS应用架构谈 网络层设计方案
iOS应用架构谈 本地持久化方案及动态部署
iOS应用架构谈 组件化方案




前言


网络层在一个App中也是一个不可缺少的部分,工程师们在网络层能够发挥的空间也比较大。另外,苹果对网络请求部分已经做了很好的封装,业界的AFNetworking也被广泛使用。其它的ASIHttpRequest,MKNetworkKit啥的其实也都还不错,但前者已经弃坑,后者也在弃坑的边缘。在实际的App开发中,Afnetworking已经成为了事实上各大App的标准配置。

网络层在一个App中承载了API调用,用户操作日志记录,甚至是即时通讯等任务。我接触过一些App(开源的和不开源的)的代码,在看到网络层这一块时,尤其是在看到各位架构师各显神通展示了各种技巧,我非常为之感到兴奋。但有的时候,往往也对于其中的一些缺陷感到失望。


关于网络层的设计方案会有很多,需要权衡的地方也会有很多,甚至于争议的地方都会有很多。但无论如何,我都不会对这些问题做出任何逃避,我会在这篇文章中给出我对它们的看法和解决方案,观点绝不中立,不会跟大家打太极。

这篇文章就主要会讲这些方面:


  1. 网络层跟业务对接部分的设计
  2. 网络层的安全机制实现
  3. 网络层的优化方案




网络层跟业务对接部分的设计


在安居客App的架构更新换代的时候,我深深地感觉到网络层跟业务对接部分的设计有多么重要,因此我对它做的最大改变就是针对网络层跟业务对接部分的改变。网络层跟业务层对接部分设计的好坏,会直接影响到业务工程师实现功能时的心情。


在正式开始讲设计之前,我们要先讨论几个问题:


  1. 使用哪种交互模式来跟业务层做对接?
  2. 是否有必要将API返回的数据封装成对象然后再交付给业务层?
  3. 使用集约化调用方式还是离散型调用方式去调用API?


这些问题讨论完毕之后,我会给出一个完整的设计方案来给大家做参考,设计方案是鱼,讨论的这些问题是渔,我什么都授了,大家各取所需。


使用哪种交互模式来跟业务层做对接?


这里其实有两个问题:


  1. 以什么方式将数据交付给业务层?
  2. 交付什么样的数据给业务层?


以什么方式将数据交付给业务层?


iOS开发领域有很多对象间数据的传递方式,我看到的大多数App在网络层所采用的方案主要集中于这三种:Delegate,Notification,Block。KVO和Target-Action我目前还没有看到有使用的。

目前我知道边锋主要是采用的block,大智慧主要采用的是Notification,安居客早期以Block为主,后面改成了以Delegate为主,阿里没发现有通过Notification来做数据传递的地方(可能有),Delegate、Block以及target-action都有,阿里iOS App网络层的作者说这是为了方便业务层选择自己合适的方法去使用。这里大家都是各显神通,每次我看到这部分的时候,我都喜欢问作者为什么采用这种交互方案,但很少有作者能够说出个条条框框来。

然而在我这边,我的意见是以Delegate为主,Notification为辅。原因如下:


  • 尽可能减少跨层数据交流的可能,限制耦合
  • 统一回调方法,便于调试和维护
  • 在跟业务层对接的部分只采用一种对接手段(在我这儿就是只采用delegate这一个手段)限制灵活性,以此来交换应用的可维护性


尽可能减少跨层数据交流的可能,限制耦合


什么叫跨层数据交流?就是某一层(或模块)跟另外的与之没有直接对接关系的层(或模块)产生了数据交换。为什么这种情况不好?严格来说应该是大部分情况都不好,有的时候跨层数据交流确实也是一种需求。之所以说不好的地方在于,它会导致代码混乱,破坏模块的封装性。我们在做分层架构的目的其中之一就在于下层对上层有一次抽象,让上层可以不必关心下层细节而执行自己的业务。

所以,如果下层细节被跨层暴露,一方面你很容易因此失去邻层对这个暴露细节的保护;另一方面,你又不可能不去处理这个细节,所以处理细节的相关代码就会散落各地,最终难以维护。

说得具象一点就是,我们考虑这样一种情况:A<-B<-C。当C有什么事件,通过某种方式告知B,然后B执行相应的逻辑。一旦告知方式不合理,让A有了跨层知道C的事件的可能,你 就很难保证A层业务工程师在将来不会对这个细节作处理。一旦业务工程师在A层产生处理操作,有可能是补充逻辑,也有可能是执行业务,那么这个细节的相关处理代码就会有一部分散落在A层。然而前者是不应该散落在A层的,后者有可能是需求。另外,因为B层是对A层抽象的,执行补充逻辑的时候,有可能和B层针对这个事件的处理逻辑产生冲突,这是我们很不希望看到的。

那么什么情况跨层数据交流会成为需求?在网络层这边,信号从2G变成3G变成4G变成Wi-Fi,这个是跨层数据交流的其中一个需求。不过其他的跨层数据交流需求我暂时也想不到了,哈哈,应该也就这一个吧。




严格来说,使用Notification来进行网络层和业务层之间数据的交换,并不代表这一定就是跨层数据交流,但是使用Notification给跨层数据交流开了一道口子,因为Notification的影响面不可控制,只要存在实例就存在被影响的可能。另外,这也会导致谁都不能保证相关处理代码就在唯一的那个地方,进而带来维护灾难。作为架构师,在这里给业务工程师限制其操作的灵活性是必要的。另外,Notification也支持一对多的情况,这也给代码散落提供了条件。同时,Notification所对应的响应方法很难在编译层面作限制,不同的业务工程师会给他取不同的名字,这也会给代码的可维护性带来灾难。

手机淘宝架构组的侠武同学曾经给我分享过一个问题,在这里我也分享给大家:曾经有一个工程师在监听Notification之后,没有写释放监听的代码,当然,找到这个原因又是很漫长的一段故事,现在找到原因了,然而监听这个Notification的对象有那么多,不知道具体是哪个Notificaiton,也不知道那个没释放监听的对象是谁。后来折腾了很久大家都没办法的时候,有一个经验丰富的工程师提出用hook(Method Swizzling)的方式,最终找到了那个没释放监听的对象,bug修复了。

我分享这个问题的目的并不是想强调Notification多么多么不好,Notification本身就是一种设计模式,在属于他的问题领域内,Notification是非常好的一种解决方案。但我想强调的是,对于网络层这个问题领域内来看,架构师首先一定要限制代码的影响范围,在能用影响范围小的方案的时候就尽量采用这种小的方案,否则将来要是有什么奇怪需求或者出了什么小问题,维护起来就非常麻烦。因此Notification这个方案不能作为首选方案,只能作为备选。

那么Notification也不是完全不能使用,当需求要求跨层时,我们就可以使用Notification,比如前面提到的网络条件切换,而且这个需求也是需要满足一对多的。

所以,为了符合前面所说的这些要求,使用Delegate能够很好地避免跨层访问,同时限制了响应代码的形式,相比Notification而言有更好的可维护性。




然后我们顺便来说说为什么尽量不要用block。


  • block很难追踪,难以维护


我们在调试的时候经常会单步追踪到某一个地方之后,发现尼玛这里有个block,如果想知道这个block里面都做了些什么事情,这时候就比较蛋疼了。


- (void)someFunctionWithBlock:(SomeBlock *)block
{
    ... ...

 -> block();  //当你单步走到这儿的时候,要想知道block里面都做了哪些事情的话,就很麻烦。

    ... ...
}


  • block会延长相关对象的生命周期


block会给内部所有的对象引用计数加一,这一方面会带来潜在的retain cycle,不过我们可以通过Weak Self的手段解决。另一方面比较重要就是,它会延长对象的生命周期。

在网络回调中使用block,是block导致对象生命周期被延长的其中一个场合,当ViewController从window中卸下时,如果尚有请求带着block在外面飞,然后block里面引用了ViewController(这种场合非常常见),那么ViewController是不能被及时回收的,即便你已经取消了请求,那也还是必须得等到请求着陆之后才能被回收。

然而使用delegate就不会有这样的问题,delegate是弱引用,哪怕请求仍然在外面飞,,ViewController还是能够及时被回收的,回收之后指针自动被置为了nil,无伤大雅。


  • block在离散型场景下不符合使用的规范


block和delegate乍看上去在作用上是很相似,但是关于它们的选型有一条严格的规范:当回调之后要做的任务在每次回调时都是一致的情况下,选择delegate,在回调之后要做的任务在每次回调时无法保证一致,选择block。在离散型调用的场景下,每一次回调都是能够保证任务一致的,因此适用delegate。这也是苹果原生的网络调用也采用delegate的原因,因为苹果也是基于离散模型去设计网络调用的,而且本文即将要介绍的网络层架构也是基于离散型调用的思路去设计的。

在集约型调用的场景下,使用block是合理的,因为每次请求的类型都不一样,那么自然回调要做的任务也都会不一样,因此只能采用block。AFNetworking就是属于集约型调用,因此它采用了block来做回调。

就我所知,目前大部分公司的App网络层都是集约型调用,因此广泛采取了block回调。但是在App的网络层架构设计中直接采用集约型调用来为业务服务的思路是有问题的,因此在迁移到离散型调用时,一定要注意这一点,记得迁回delegate回调。关于离散型和集约型调用的介绍和如何选型,我在后面的集约型API调用方式和离散型API调用方式的选择?小节中有详细的介绍。


所以平时尽量不要滥用block,尤其是在网络层这里。





统一回调方法,便于调试和维护


前面讲的是跨层问题,区分了Delegate和Notification,顺带谈了一下Block。然后现在谈到的这个情况,就是另一个采用Block方案不是很合适的情况。首先,Block本身无好坏对错之分,只有合适不合适。在这一节要讲的情况里,Block无法做到回调方法的统一,调试和维护的时候也很难在调用栈上显示出来,找的时候会很蛋疼。

在网络请求和网络层接受请求的地方时,使用Block没问题。但是在获得数据交给业务方时,最好还是通过Delegate去通知到业务方。因为Block所包含的回调代码跟调用逻辑放在同一个地方,会导致那部分代码变得很长,因为这里面包括了调用前和调用后的逻辑。从另一个角度说,这在一定程度上违背了single function,single task的原则,在需要调用API的地方,就只要写API调用相关的代码,在回调的地方,写回调的代码。

然后我看到大部分App里,当业务工程师写代码写到这边的时候,也意识到了这个问题。因此他们会在block里面写个一句话的方法接收参数,然后做转发,然后就可以把这个方法放在其他地方了,绕过了Block的回调着陆点不统一的情况。比如这样:


    [API callApiWithParam:param successed:^(Response *response){
        [self successedWithResponse:response];
    } failed:^(Request *request, NSError *error){
        [self failedWithRequest:request error:error];
    }];


这实质上跟使用Delegate的手段没有什么区别,只是绕了一下,不过还是没有解决统一回调方法的问题,因为block里面写的方法名字可能在不同的ViewController对象中都会不一样,毕竟业务工程师也是很多人,各人有各人的想法。所以架构师在这边不要贪图方便,还是使用delegate的手段吧,业务工程师那边就能不用那么绕了。Block是目前大部分第三方网络库都采用的方式,因为在发送请求的那一部分,使用Block能够比较简洁,因此在请求那一层是没有问题的,只是在交换数据之后,还是转变成delegate比较好,比如AFNetworking里面:


    [AFNetworkingAPI callApiWithParam:self.param successed:^(Response *response){
        if ([self.delegate respondsToSelector:@selector(successWithResponse:)]) {
            [self.delegate successedWithResponse:response];
        }
    } failed:^(Request *request, NSError *error){
        if ([self.delegate respondsToSelector:@selector(failedWithResponse:)]) {
            [self failedWithRequest:request error:error];
        }
    }];


这样在业务方这边回调函数就能够比较统一,便于维护。




综上,对于以什么方式将数据交付给业务层?这个问题的回答是这样:

尽可能通过Delegate的回调方式交付数据,这样可以避免不必要的跨层访问。当出现跨层访问的需求时(比如信号类型切换),通过Notification的方式交付数据。正常情况下应该是避免使用Block的。




交付什么样的数据给业务层?


我见过非常多的App的网络层在拿到JSON数据之后,会将数据转变成对应的对象原型。注意,我这里指的不是NSDictionary,而是类似Item这样的对象。这种做法是能够提高后续操作代码的可读性的。在比较直觉的思路里面,是需要这部分转化过程的,但这部分转化过程的成本是很大的,主要成本在于:


  1. 数组内容的转化成本较高:数组里面每项都要转化成Item对象,如果Item对象中还有类似数组,就很头疼。
  2. 转化之后的数据在大部分情况是不能直接被展示的,为了能够被展示,还需要第二次转化。
  3. 只有在API返回的数据高度标准化时,这些对象原型(Item)的可复用程度才高,否则容易出现类型爆炸,提高维护成本。
  4. 调试时通过对象原型查看数据内容不如直接通过NSDictionary/NSArray直观。
  5. 同一API的数据被不同View展示时,难以控制数据转化的代码,它们有可能会散落在任何需要的地方。


其实我们的理想情况是希望API的数据下发之后就能够直接被View所展示。首先要说的是,这种情况非常少。另外,这种做法使得View和API联系紧密,也是我们不希望发生的。

在设计安居客的网络层数据交付这部分时,我添加了reformer(名字而已,叫什么都好)这个对象用于封装数据转化的逻辑,这个对象是一个独立对象,事实上,它是作为Adaptor模式存在的。我们可以这么理解:想象一下我们洗澡时候使用的莲蓬头,水管里出来的水是API下发的原始数据。reformer就是莲蓬头上的不同水流挡板,需要什么模式,就拨到什么模式。

在实际使用时,代码观感是这样的:


先定义一个protocol

@protocol ReformerProtocol <NSObject>
- (NSDictionary)reformDataWithManager:(APIManager *)manager;
@end


在Controller里是这样

@property (nonatomic, strong) id<ReformerProtocol> XXXReformer;
@property (nonatomic, strong) id<ReformerProtocol> YYYReformer;

#pragma mark - APIManagerDelegate
- (void)apiManagerDidSuccess:(APIManager *)manager
{
    NSDictionary *reformedXXXData = [manager fetchDataWithReformer:self.XXXReformer];
    [self.XXXView configWithData:reformedXXXData];

    NSDictionary *reformedYYYData = [manager fetchDataWithReformer:self.YYYReformer];
    [self.YYYView configWithData:reformedYYYData];
}


在APIManager里面fetchDataWithReformer是这样
- (NSDictionary)fetchDataWithReformer:(id<ReformerProtocol>)reformer
{
    if (reformer == nil) {
        return self.rawData;
    } else {
        return [reformer reformDataWithManager:self];
    }
}


  • 要点1:reformer是一个符合ReformerProtocol的对象,它提供了通用的方法供Manager使用。


  • 要点2:API的原始数据(JSON对象)由Manager实例保管,reformer方法里面取Manager的原始数据(manager.rawData)做转换,然后交付出去。莲蓬头的水管部分是Manager,负责提供原始水流(数据流),reformer就是不同的模式,换什么reformer就能出来什么水流。


  • 要点3:例子中举的场景是一个API数据被多个View使用的情况,体现了reformer的一个特点:可以根据需要改变同一数据来源的展示方式。比如API数据展示的是“附近的小区”,那么这个数据可以被列表(XXXView)和地图(YYYView)共用,不同的view使用的数据的转化方式不一样,这就通过不同的reformer解决了。


  • 要点4:在一个view用来同一展示不同API数据的情况,reformer是绝佳利器。比如安居客的列表view的数据来源可能有三个:二手房列表API,租房列表API,新房列表API。这些API返回来的数据的value可能一致,但是key都是不一致的。这时候就可以通过同一个reformer来做数据的标准化输出,这样就使得view代码复用成为可能。这体现了reformer另外一个特点:同一个reformer出来的数据是高度标准化的。形象点说就是:只要莲蓬头不换,哪怕水管的水变成海水或者污水了,也依旧能够输出符合洗澡要求的淡水水流。举个例子:


- (void)apiManagerDidSuccess:(APIManager *)manager
{
    // 这个回调方法有可能是来自二手房列表APIManager的回调,也有可能是租房,也有可能是新房。但是在Controller层面我们不需要对它做额外区分,只要是同一个reformer出来的数据,我们就能保证是一定能被self.XXXView使用的。这样的保证由reformer的实现者来提供。
    NSDictionary *reformedXXXData = [manager fetchDataWithReformer:self.XXXReformer];
    [self.XXXView configWithData:reformedXXXData];
}


  • 要点5:有没有发现,使用reformer之后,Controller的代码简洁了很多?而且,数据原型在这种情况下就没有必要存在了,随之而来的成本也就被我们绕过了。




reformer本质上就是一个符合某个protocol的对象,在controller需要从api manager中获得数据的时候,顺便把reformer传进去,于是就能获得经过reformer重新洗过的数据,然后就可以直接使用了。

更抽象地说,reformer其实是对数据转化逻辑的一个封装。在controller从manager中取数据之后,并且把数据交给view之前,这期间或多或少都是要做一次数据转化的,有的时候不同的view,对应的转化逻辑还不一样,但是展示的数据是一样的。而且往往这一部分代码都非常复杂,且跟业务强相关,直接上代码,将来就会很难维护。所以我们可以考虑采用不同的reformer封装不同的转化逻辑,然后让controller根据需要选择一个合适的reformer装上,就像洗澡的莲蓬头,需要什么样的水流(数据的表现形式)就换什么样的头,然而水(数据)都是一样的。这种做法能够大大提高代码的可维护性,以及减少ViewController的体积。

总结一下,reformer事实上是把转化的代码封装之后再从主体业务中拆分了出来,拆分出来之后不光降低了原有业务的复杂度,更重要的是,它提高了数据交付的灵活性。另外,由于Controller负责调度Manager和View,因此它是知道Manager和View之间的关系的,Controller知道了这个关系之后,就有了充要条件来为不同的View选择不同的Reformer,并用这个Reformer去改造Mananger的数据,然后ViewController获得了经过reformer处理过的数据之后,就可以直接交付给view去使用。Controller因此得到瘦身,负责业务数据转化的这部分代码也不用写在Controller里面,提高了可维护性。




所以reformer机制能够带来以下好处:

  • 好处1:绕开了API数据原型的转换,避免了相关成本。

  • 好处2:在处理单View对多API,以及在单API对多View的情况时,reformer提供了非常优雅的手段来响应这种需求,隔离了转化逻辑和主体业务逻辑,避免了维护灾难。

  • 好处3:转化逻辑集中,且将转化次数转为只有一次。使用数据原型的转化逻辑至少有两次,第一次是把JSON映射成对应的原型,第二次是把原型转变成能被View处理的数据。reformer一步到位。另外,转化逻辑在reformer里面,将来如果API数据有变,就只要去找到对应reformer然后改掉就好了。

  • 好处4:Controller因此可以省去非常多的代码,降低了代码复杂度,同时提高了灵活性,任何时候切换reformer而不必切换业务逻辑就可以应对不同View对数据的需要。

  • 好处5:业务数据和业务有了适当的隔离。这么做的话,将来如果业务逻辑有修改,换一个reformer就好了。如果其他业务也有相同的数据转化逻辑,其他业务直接拿这个reformer就可以用了,不用重写。另外,如果controller有修改(比如UI交互方式改变),可以放心换controller,完全不用担心业务数据的处理。




在不使用特定对象表征数据的情况下,如何保持数据可读性?


不使用对象来表征数据的时候,事实上就是使用NSDictionary的时候。事实上,这个问题就是,如何在NSDictionary表征数据的情况下保持良好的可读性?

苹果已经给出了非常好的做法,用固定字符串做key,比如你在接收到KeyBoardWillShow的Notification时,带了一个userInfo,他的key就都是类似UIKeyboardAnimationCurveUserInfoKey这样的,所以我们采用这样的方案来维持可读性。下面我举一个例子:


PropertyListReformerKeys.h

extern NSString * const kPropertyListDataKeyID;
extern NSString * const kPropertyListDataKeyName;
extern NSString * const kPropertyListDataKeyTitle;
extern NSString * const kPropertyListDataKeyImage;


PropertyListReformer.h

#import "PropertyListReformerKeys.h"

... ...


PropertyListReformer.m

NSString * const kPropertyListDataKeyID = @"kPropertyListDataKeyID";
NSString * const kPropertyListDataKeyName = @"kPropertyListDataKeyName";
NSString * const kPropertyListDataKeyTitle = @"kPropertyListDataKeyTitle";
NSString * const kPropertyListDataKeyImage = @"kPropertyListDataKeyImage";

- (NSDictionary *)reformData:(NSDictionary *)originData fromManager:(APIManager *)manager
{
    ... ...
    ... ...

    NSDictionary *resultData = nil;

    if ([manager isKindOfClass:[ZuFangListAPIManager class]]) {
        resultData = @{
            kPropertyListDataKeyID:originData[@"id"],
            kPropertyListDataKeyName:originData[@"name"],
            kPropertyListDataKeyTitle:originData[@"title"],
            kPropertyListDataKeyImage:[UIImage imageWithUrlString:originData[@"imageUrl"]]
        };
    }

    if ([manager isKindOfClass:[XinFangListAPIManager class]]) {
        resultData = @{
            kPropertyListDataKeyID:originData[@"xinfang_id"],
            kPropertyListDataKeyName:originData[@"xinfang_name"],
            kPropertyListDataKeyTitle:originData[@"xinfang_title"],
            kPropertyListDataKeyImage:[UIImage imageWithUrlString:originData[@"xinfang_imageUrl"]]
        };
    }

    if ([manager isKindOfClass:[ErShouFangListAPIManager class]]) {
        resultData = @{
            kPropertyListDataKeyID:originData[@"esf_id"],
            kPropertyListDataKeyName:originData[@"esf_name"],
            kPropertyListDataKeyTitle:originData[@"esf_title"],
            kPropertyListDataKeyImage:[UIImage imageWithUrlString:originData[@"esf_imageUrl"]]
        };
    }

    return resultData;
}


PropertListCell.m

#import "PropertyListReformerKeys.h"

- (void)configWithData:(NSDictionary *)data
{
    self.imageView.image = data[kPropertyListDataKeyImage];
    self.idLabel.text = data[kPropertyListDataKeyID];
    self.nameLabel.text = data[kPropertyListDataKeyName];
    self.titleLabel.text = data[kPropertyListDataKeyTitle];
}


这一大段代码看下来,我如果不说一下要点,那基本上就白写了哈:

我们先看一下结构:


    ----------------------------------          -----------------------------------------
    |                                |          |                                       |
    | PropertyListReformer.m         |          | PropertyListReformer.h                |
    |                                |          |                                       |
    | #import PropertyListReformer.h | <------- |  #import "PropertyListReformerKeys.h" |
    | NSString * const key = @"key"  |          |                                       |
    |                                |          |                                       |
    ----------------------------------          -----------------------------------------
                                                                    .
                                                                   /|\
                                                                    |
                                                                    |
                                                                    |
                                                                    |
                                                    ---------------------------------
                                                    |                               |
                                                    | PropertyListReformerKeys.h    |
                                                    |                               |
                                                    | extern NSString * const key;  |
                                                    |                               |
                                                    ---------------------------------


使用Const字符串来表征Key,字符串的定义跟着reformer的实现文件走,字符串的extern声明放在独立的头文件内。

这样reformer生成的数据的key都使用Const字符串来表示,然后每次别的地方需要使用相关数据的时候,把PropertyListReformerKeys.h这个头文件import进去就好了。

另外要注意的一点是,如果一个OriginData可能会被多个Reformer去处理的话,Key的命名规范需要能够表征出其对应的reformer名字。如果reformer是PropertyListReformer,那么Key的名字就是PropertyListKeyXXXX

这么做的好处就是,将来迁移的时候相当方便,只要扔头文件就可以了,只扔头文件是不会导致拔出萝卜带出泥的情况的。而且也避免了自定义对象带来的额外代码体积。




另外,关于交付的NSDictionary,其实具体还是看view的需求,reformer的设计初衷是:通过reformer转化出来的可以直接是View,或者是view直接可以使用的对象(包括NSDictionary)。比如地图标点列表API的数据,通过reformer转化之后就可以直接变成MKAnnotation,然后MKMapView就可以直接使用了。这里说的只是当你的需求是交付NSDictionary时,如何保证可读性的情况,再强调一下哈,reformer交付的是view直接可以使用的对象,交付出去的可以是NSDictionary,也可以是UIView,跟DataSource结合之后交付的甚至可以是UITableViewCell/UICollectionViewCell。不要被NSDictionary或所谓的转化成model再交付的思想局限。




综上,我对交付什么样的数据给业务层?这个问题的回答就是这样:

对于业务层而言,由Controller根据View和APIManager之间的关系,选择合适的reformer将View可以直接使用的数据(甚至reformer可以用来直接生成view)转化好之后交付给View。对于网络层而言,只需要保持住原始数据即可,不需要主动转化成数据原型。然后数据采用NSDictionary加Const字符串key来表征,避免了使用对象来表征带来的迁移困难,同时不失去可读性。






集约型API调用方式和离散型API调用方式的选择?


集约型API调用其实就是所有API的调用只有一个类,然后这个类接收API名字,API参数,以及回调着陆点(可以是target-action,或者block,或者delegate等各种模式的着陆点)作为参数。然后执行类似startRequest这样的方法,它就会去根据这些参数起飞去调用API了,然后获得API数据之后再根据指定的着陆点去着陆。比如这样:


集约型API调用方式

[APIRequest startRequestWithApiName:@"itemList.v1" params:params success:@selector(success:) fail:@selector(fail:) target:self];



离散型API调用是这样的,一个API对应于一个APIManager,然后这个APIManager只需要提供参数就能起飞,API名字、着陆方式都已经集成入APIManager中。比如这样:


离散型API调用方式

@property (nonatomic, strong) ItemListAPIManager *itemListAPIManager;

// getter
- (ItemListAPIManager *)itemListAPIManager
{
    if (_itemListAPIManager == nil) {
        _itemListAPIManager = [[ItemListAPIManager alloc] init];
        _itemListAPIManager.delegate = self;
    }

    return _itemListAPIManager;
}

// 使用的时候就这么写:
[self.itemListAPIManager loadDataWithParams:params];



集约型API调用和离散型API调用这两者实现方案不是互斥的,单看下层,大家都是集约型。因为发起一个API请求之后,除去业务相关的部分(比如参数和API名字等),剩下的都是要统一处理的:加密,URL拼接,API请求的起飞和着陆,这些处理如果不用集约化的方式来实现,作者非癫即痴。然而对于整个网络层来说,尤其是业务方使用的那部分,我倾向于提供离散型的API调用方式,并不建议在业务层的代码直接使用集约型的API调用方式。原因如下:


  • 原因1:当前请求正在外面飞着的时候,根据不同的业务需求存在两种不同的请求起飞策略:一个是取消新发起的请求,等待外面飞着的请求着陆。另一个是取消外面飞着的请求,让新发起的请求起飞。集约化的API调用方式如果要满足这样的需求,那么每次要调用的时候都要多写一部分判断和取消的代码,手段就做不到很干净。


前者的业务场景举个例子就是刷新页面的请求,刷新详情,刷新列表等。后者的业务场景举个例子是列表多维度筛选,比如你先筛选了商品类型,然后筛选了价格区间。当然,后者的情况不一定每次筛选都要调用API,我们先假设这种筛选每次都必须要通过调用API才能获得数据。

如果是离散型的API调用,在编写不同的APIManager时候就可以针对不同的API设置不同的起飞策略,在实际使用的时候,就可以不必关心起飞策略了,因为APIMananger里面已经写好了。



  • 原因2:便于针对某个API请求来进行AOP。在集约型的API调用方式下,如果要针对某个API请求的起飞和着陆过程进行AOP,这代码得写成什么样。。。噢,尼玛这画面太美别说看了,我都不敢想。



  • 原因3:当API请求的着陆点消失时,离散型的API调用方式能够更加透明地处理这种情况。


当一个页面的请求正在天上飞的时候,用户等了好久不耐烦了,小手点了个back,然后ViewController被pop被回收。此时请求的着陆点就没了。这是很危险的情况,着陆点要是没了,就很容易crash的。一般来说处理这个情况都是在dealloc的时候取消当前页面所有的请求。如果是集约型的API调用,这个代码就要写到ViewController的dealloc里面,但如果是离散型的API调用,这个代码写到APIManager里面就可以了,然后随着ViewController的回收进程,APIManager也会被跟着回收,这部分代码就得到了调用的机会。这样业务方在使用的时候就可以不必关心着陆点消失的情况了,从而更加关注业务。



  • 原因4:离散型的API调用方式能够最大程度地给业务方提供灵活性,比如reformer机制就是基于离散型的API调用方式的。另外,如果是针对提供翻页机制的API,APIManager就能简单地提供loadNextPage方法去加载下一页,页码的管理就不用业务方去管理了。还有就是,如果要针对业务请求参数进行验证,比如用户填写注册信息,在离散型的APIManager里面实现就会非常轻松。



综上,关于集约型的API调用和离散型的API调用,我倾向于这样:对外提供一个BaseAPIManager来给业务方做派生,在BaseManager里面采用集约化的手段组装请求,放飞请求,然而业务方调用API的时候,则是以离散的API调用方式来调用。如果你的App只提供了集约化的方式,而没有离散方式的通道,那么我建议你再封装一层,便于业务方使用离散的API调用方式来放飞请求。






怎么做APIManager的继承?


如果要做成离散型的API调用,那么使用继承是逃不掉的。BaseAPIManager里面负责集约化的部分,外部派生的XXXAPIManager负责离散的部分,对于BaseAPIManager来说,离散的部分有一些是必要的,比如API名字等,而我们派生的目的,也是为了提供这些数据。

我在这篇文章里面列举了种种继承的坏处,呼吁大家尽量不要使用继承。但是现在到了不得不用继承的时候,所以我得提醒一下大家别把继承用坏了。

在APIManager的情况下,我们最直觉的思路是BaseAPIManager提供一些空方法来给子类做重载,比如apiMethodName这样的函数,然而我的建议是,不要这么做。我们可以用IOP的方式来限制派生类的重载。

大概就是长这样:


BaseAPIManager的init方法里这么写

// 注意是weak。
@property (nonatomic, weak) id<APIManager> child;

- (instancetype)init
{
    self = [super init];
    if ([self conformsToProtocol:@protocol(APIManager)]) {
        self.child = (id<APIManager>)self;
    } else {
        // 不遵守这个protocol的就让他crash,防止派生类乱来。
        NSAssert(NO, "子类必须要实现APIManager这个protocol。");
    }
    return self;
}

protocol这么写把原本要重载的函数都定义在这个protocol里面就不用在父类里面写空方法了
@protocol APIManager <NSObject>

@required
- (NSString *)apiMethodName;
...

@end

然后在父类里面如果要使用的话就这么写

[self requestWithAPIName:[self.child apiMethodName] ......];


简单说就是在init的时候检查自己是否符合预先设计的子类的protocol,这就要求所有子类必须遵守这个protocol,所有针对父类的重载、覆盖也都以这个protocol为准,protocol以外的方法不允许重载、覆盖。而在父类的代码里,可以不必遵守这个protocol,保持了未来维护的灵活性。

这么做的好处就是避免了父类写空方法,同时也给子类带上了紧箍咒:要想当我的孩子,就要遵守这些规矩,不能乱来。业务方在实现子类的时候,就可以根据protocol中的方法去一一实现,然后约定就比较好做了:不允许重载父类方法,只允许选择实现或不实现protocol中的方法。

关于这个的具体的论述在这篇文章里面有,感兴趣的话可以看看。




网络层与业务层对接部分的小总结


这一节主要是讲了以下这些点:


  1. 使用delegate来做数据对接,仅在必要时采用Notification来做跨层访问
  2. 交付NSDictionary给业务层,使用Const字符串作为Key来保持可读性
  3. 提供reformer机制来处理网络层反馈的数据,这个机制很重要,好处极多
  4. 网络层上部分使用离散型设计,下部分使用集约型设计
  5. 设计合理的继承机制,让派生出来的APIManager受到限制,避免混乱
  6. 应该不止这5点...




网络层的安全机制



判断API的调用请求是来自于经过授权的APP


使用这个机制的目的主要有两点:


  1. 确保API的调用者是来自你自己的APP,防止竞争对手爬你的API
  2. 如果你对外提供了需要注册才能使用的API平台,那么你需要有这个机制来识别是否是注册用户调用了你的API



解决方案:设计签名


要达到第一个目的其实很简单,服务端需要给你一个密钥,每次调用API时,你使用这个密钥再加上API名字和API请求参数算一个hash出来,然后请求的时候带上这个hash。服务端收到请求之后,按照同样的密钥同样的算法也算一个hash出来,然后跟请求带来的hash做一个比较,如果一致,那么就表示这个API的调用者确实是你的APP。为了不让别人也获取到这个密钥,你最好不要把这个密钥存储在本地,直接写死在代码里面就好了。另外适当增加一下求Hash的算法的复杂度,那就是各种Hash算法(比如MD5)加点盐,再回炉跑一次Hash啥的。这样就能解决第一个目的了:确保你的API是来自于你自己的App。

一般情况下大部分公司不会出现需要满足第二种情况的需求,除非公司开发了自己的API平台给第三方使用。这个需求跟上面的需求有一点不同:符合授权的API请求者不只是一个。所以在这种情况下,需要的安全机制会更加复杂一点。

这里有一个较容易实现的方案:客户端调用API的时候,把自己的密钥通过一个可逆的加密算法加密后连着请求和加密之后的Hash一起送上去。当然,这个可逆的加密算法肯定是放在在调用API的SDK里面,编译好的。然后服务端拿到加密后的密钥和加密的Hash之后,解码得到原始密钥,然后再用它去算Hash,最后再进行比对。



保证传输数据的安全


使用这个机制的主要目的有两点:


  1. 防止中间人攻击,比如说运营商很喜欢往用户的Http请求里面塞广告...
  2. SPDY依赖于HTTPS,而且是未来HTTP/2的基础,他们能够提高你APP在网络层整体的性能。



解决方案:HTTPS

目前使用HTTPS的主要目的在于防止运营商往你的Response Data里面加广告啥的(中间人攻击),面对的威胁范围更广。从2011年开始,国外业界就已经提倡所有的请求(不光是API,还有网站)都走HTTPS,国内差不多晚了两年(2013年左右)才开始提倡这事,天猫是这两个月才开始做HTTPS的全APP迁移。

关于速度,HTTPS肯定是比HTTP慢的,毕竟多了一次握手,但挂上SPDY之后,有了链接复用,这方面的性能就有了较大提升。这里的性能提升并不是说一个请求原来要500ms能完成,然后现在只要300ms,这是不对的。所谓整体性能是基于大量请求去讨论的:同样的请求量(假设100个)在短期发生时,挂上SPDY之后完成这些任务所要花的时间比不用SPDY要少。SPDY还有Header压缩的功能,不过因为一个API请求本身已经比较小了,压缩数据量所带来的性能提升不会特别明显,所以就单个请求来看,性能的提升是比较小的。不过这是下一节要讨论的事儿了,这儿只是顺带说一下。



安全机制小总结


这一节说了两种安全机制,一般来说第一种是标配,第二种属于可选配置。不过随着我国互联网基础设施的完善,移动设备性能的提高,以及优化技术的提高,第二种配置的缺点(速度慢)正在越来越微不足道,因此HTTPS也会成为不久之后的未来App的网络层安全机制标配。各位架构师们,如果你的App还没有挂HTTPS,现在就已经可以开始着手这件事情了。




网络层的优化方案


网络层的优化手段主要从以下三方面考虑:


  1. 针对链接建立环节的优化
  2. 针对链接传输数据量的优化
  3. 针对链接复用的优化


这三方面是所有优化手段的内容,各种五花八门的优化手段基本上都不会逃脱这三方面,下面我就会分别针对这三方面讲一下各自对应的优化手段。


1. 针对链接建立环节的优化

在API发起请求建立链接的环节,大致会分这些步骤:


  1. 发起请求
  2. DNS域名解析得到IP
  3. 根据IP进行三次握手(HTTPS四次握手),链接建立成功


其实第三步的优化手段跟第二步的优化手段是一致的,我会在讲第二步的时候一起讲掉。



1.1 针对发起请求的优化手段


其实要解决的问题就是网络层该不该为此API调用发起请求。



  • 1.1.1 使用缓存手段减少请求的发起次数


对于大部分API调用请求来说,有些API请求所带来的数据的时效性是比较长的,比如商品详情,比如App皮肤等。那么我们就可以针对这些数据做本地缓存,这样下次请求这些数据的时候就可以不必再发起新的请求。

一般是把API名字和参数拼成一个字符串然后取MD5作为key,存储对应返回的数据。这样下次有同样请求的时候就可以直接读取这里面的数据。关于这里有一个缓存策略的问题需要讨论:什么时候清理缓存?要么就是根据超时时间限制进行清理,要么就是根据缓存数据大小进行清理。这个策略的选择要根据具体App的操作日志来决定。

比如安居客App,日志数据记录显示用户平均使用时长不到3分钟,但是用户查看房源详情的次数比较多,而房源详情数据量较大。那么这个时候,就适合根据使用时长来做缓存,我当时给安居客设置的缓存超时时间就是3分钟,这样能够保证这个缓存能够在大部分用户使用时间产生作用。嗯,极端情况下做什么缓存手段不考虑,只要能够服务好80%的用户就可以了,而且针对极端情况采用的优化手段对大部分普通用户而言是不必要的,做了反而会对他们有影响。

再比如网络图片缓存,数据量基本上都特别大,这种就比较适合针对缓存大小来清理缓存的策略。

另外,之前的缓存的前提都是基于内存的。我们也可以把需要清理的缓存存储在硬盘上(APP的本地存储,我就先用硬盘来表示了,虽然很少有手机硬盘的说法,哈哈),比如前面提到的图片缓存,因为图片很有可能在很长时间之后,再被显示的,那么原本需要被清理的图片缓存,我们就可以考虑存到硬盘上去。当下次再有显示网络图片的需求的时候,我们可以先从内存中找,内存找不到那就从硬盘上找,这都找不到,那就发起请求吧。

当然,有些时效性非常短的API数据,就不能使用这个方法了,比如用户的资金数据,那就需要每次都调用了。



  • 1.1.2 使用策略来减少请求的发起次数


这个我在前面提到过,就是针对重复请求的发起和取消,是有对应的请求策略的。我们先说取消策略。

如果是界面刷新请求这种,而且存在重复请求的情况(下拉刷新时,在请求着陆之前用户不断执行下拉操作),那么这个时候,后面重复操作导致的API请求就可以不必发送了。

如果是条件筛选这种,那就取消前面已经发送的请求。虽然很有可能这个请求已经被执行了,那么取消所带来的性能提升就基本没有了。但如果这个请求还在队列中待执行的话,那么对应的这次链接就可以省掉了。


以上是一种,另外一种情况就是请求策略:类似用户操作日志的请求策略。

用户操作会触发操作日志上报Server,这种请求特别频繁,但是是暗地里进行的,不需要用户对此有所感知。所以也没必要操作一次就发起一次的请求。在这里就可以采用这样的策略:在本地记录用户的操作记录,当记录满30条的时候发起一次请求将操作记录上传到服务器。然后每次App启动的时候,上传一次上次遗留下来没上传的操作记录。这样能够有效降低用户设备的耗电量,同时提升网络层的性能。



小总结


针对建立连接这部分的优化就是这样的原则:能不发请求的就尽量不发请求,必须要发请求时,能合并请求的就尽量合并请求。然而,任何优化手段都是有前提的,而且也不能保证对所有需求都能起作用,有些API请求就是不符合这些优化手段前提的,那就老老实实发请求吧。不过这类API请求所占比例一般不大,大部分的请求都或多或少符合优化条件,所以针对发送请求的优化手段还是值得做的。



1.2 & 1.3 针对DNS域名解析做的优化,以及建立链接的优化


其实在整个DNS链路上也是有DNS缓存的,理论上也是能够提高速度的。这个链路上的DNS缓存在PC用户上效果明显,因为PC用户的DNS链路相对稳定,信号源不会变来变去。但是在移动设备的用户这边,链路上的DNS缓存所带来的性能提升就不太明显了。因为移动设备的实际使用场景比较复杂,网络信号源会经常变换,信号源每变换一次,对应的DNS解析链路就会变换一次,那么原链路上的DNS缓存就不起作用了。而且信号源变换的情况特别特别频繁,所以对于移动设备用户来说,链路的DNS缓存我们基本上可以默认为没有。所以大部分时间是手机系统自带的本地DNS缓存在起作用,但是一般来说,移动设备上网的需求也特别频繁,专门为我们这个App所做的DNS缓存很有可能会被别的DNS缓存给挤出去被清理掉,这种情况是特别多的,用户看一会儿知乎刷一下微博查一下地图逛一逛点评再聊个Q,回来之后很有可能属于你自己的App的本地DNS缓存就没了。这还没完,这里还有一个只有在中国特色社会主义的互联网环境中才会有的问题:国内的互联网环境由于GFW的存在,就使得DNS服务速度会比正常情况慢不少。

基于以上三个原因所导致的最终结果就是,API请求在DNS解析阶段的耗时会很多。

那么针对这个的优化方案就是,索性直接走IP请求,那不就绕过DNS服务的耗时了嘛。




另外一个,就是上面提到的建立链接时候的第三步,国内的网络环境分北网通南电信(当然实际情况更复杂,这里随便说说),不同服务商之间的连接,延时是很大的,我们需要想办法让用户在最适合他的IP上给他提供服务,那么就针对我们绕过DNS服务的手段有一个额外要求:尽可能不要让用户使用对他来说很慢的IP。

所以综上所述,方案就应该是这样:本地有一份IP列表,这些IP是所有提供API的服务器的IP,每次应用启动的时候,针对这个列表里的所有IP取ping延时时间,然后取延时时间最小的那个IP作为今后发起请求的IP地址。




针对建立连接的优化手段其实是跟DNS域名解析的优化手段是一样的。不过这需要你的服务器提供服务的网络情况要多,一般现在的服务器都是双网卡,电信和网通。由于中国特色的互联网ISP分布,南北网络之间存在瓶颈,而我们App针对链接的优化手段主要就是着手于如何减轻这个瓶颈对App产生的影响,所以需要维护一个IP列表,这样就能就近连接了,就起到了优化的效果。


我们一般都是在应用启动的时候获得本地列表中所有IP的ping值,然后通过NSURLProtocol的手段将URL中的HOST修改为我们找到的最快的IP。另外,这个本地IP列表也会需要通过一个API来维护,一般是每天第一次启动的时候读一次API,然后更新到本地。

如果你还不熟悉NSURLProtocol应该怎么玩,看完官方文档这篇文章以及这个Demo之后,你肯定就会了,其实很简单的。另外,刚才提到那篇文章的作者(mattt)还写了这个基于NSURLProtocol的工具,相当好用,是可以直接拿来集成到项目中的。

不用NSURLProtocol的话,用其他手段也可以做到这一点,但那些手段未免又比较愚蠢。



2. 针对链接传输数据量的优化


这个很好理解,传输的数据少了,那么自然速度就上去了。这里没什么花样可以讲的,就是压缩呗。各种压缩。



3. 针对链接复用的优化


建立链接本身是属于比较消耗资源的操作,耗电耗时。SPDY自带链接复用以及数据压缩的功能,所以服务端支持SPDY的时候,App直接挂SPDY就可以了。如果服务端不支持SPDY,也可以使用PipeLine,苹果原生自带这个功能。

一般来说业界内普遍的认识是SPDY优于PipeLine,然后即便如此,SPDY能够带来的网络层效率提升其实也没有文献上的图表那么明显,但还是有性能提升的。还有另外一种比较笨的链接复用的方法,就是维护一个队列,然后将队列里的请求压缩成一个请求发出去,之所以会存在滞留在队列中的请求,是因为在上一个请求还在外面飘的时候。这种做法最终的效果表面上看跟链接复用差别不大,但并不是真正的链接复用,只能说是请求合并。

还是说回来,我建议最好是用SPDY,SPDY和pipeline虽然都属于链接复用的范畴,但是pipeline并不是真正意义上的链接复用,SPDY的链接复用相对pipeline而言更为彻底。SPDY目前也有现成的客户端SDK可以使用,一个是twitter的CocoaSPDY,另一个是Voxer/iSPDY,这两个库都很活跃,大家可以挑合适的采用。

不过目前业界趋势是倾向于使用HTTP/2.0来代替SPDY,不过目前HTTP/2.0还没有正式出台,相关实现大部分都处在demo阶段,所以我们还是先SPDY搞起就好了。未来很有可能会放弃SPDY,转而采用HTTP/2.0来实现网络的优化。这是要提醒各位架构师注意的事情。嗯,我也不知道HTTP/2.0什么时候能出来。






渔说完了,鱼来了


这里是我当年设计并实现的安居客的网络层架构代码。当然,该脱敏的地方我都已经脱敏了,所以编不过是正常的,哈哈哈。但是代码比较齐全,重要地方注释我也写了很多。另外,为了让大家能够把这些代码看明白,我还附带了当年介绍这个框架演讲时的PPT。(补充说明一下,评论区好多人问PPT找不着在哪儿,PPT也在上面提到的repo里面,是个key后缀名的文件,用keynote打开)

然后就是,当年也有很多问题其实考虑得并没有现在清楚,所以有些地方还是做得不够好,比如拦截器和继承。而且当时的优化手段只有本地cache,安居客没有那么多IP可以给我ping,当年也没流行SPDY,而且API也还不支持HTTPS,所以当时的代码里面没有在这些地方做优化,比较原始。然而整个架构的基本思路一直没有变化:优先服务于业务方。另外,安居客的网络层多了一个service的概念,这是我这篇文章中没有讲的。主要是因为安居客的API提供方很多,二手房,租房,新房,X项目等等API都是不同的API team提供的,以service作区分,如果你的app也是类似的情况,我也建议你设计一套service机制。现在这些service被我删得只剩下一个google的service,因为其他service都属于敏感内容。

另外,这里面提供的PPT我很希望大家能够花时间去看看,在PPT里面有些更加细的东西我在博客里没有写,主要是我比较懒,然后这篇文章拖的时间比较长了,花时间搬运这个没什么意思,不过内容还是值得各位读者去看的。关于PPT里面大家有什么问题的,也可以在评论区问,我都会回答。




总结


第一部分主要讲了网络层应当如何跟业务层进行数据交互,进行数据交互时采用怎样的数据格式,以及设计时代码结构上的一些问题,诸如继承的处理,回调的处理,交互方式的选择,reformer的设计,保持数据可读性等等等等,主要偏重于设计(这可是艺术活,哈哈哈)。

第二部分讲了网络安全上,客户端要做的两点。当然,从网络安全的角度上讲,服务端也要做很多很多事情,客户端要做的一些边角细节的事情也还会有很多,比如做一些代码混淆,尽可能避免代码中明文展示key。不过大头主要就是这两个,而且也都是需要服务端同学去配合的。主要偏重于介绍。(主要是也没啥好实践的,google一下教程照着来就好了)。

第三部分讲了优化,优化的所有方面都已经列出来了,如果业界再有七七八八的别的手段,也基本逃离不出本文的范围。这里有些优化手段是需要服务端同学配合的,有些不需要,大家看各自情况来决定。主要偏重于实践。

最后给出了我之前在安居客做的网络层架构的主要代码,以及当时演讲时的PPT。关于代码或PPT中有任何问题,都可以在评论区问我。

这一篇文章出得比较晚,因为公司的事情,中间间隔了一个礼拜,希望大家谅解。另外,隔了一个礼拜之后我再写,发现有些地方我已经想不起来当初是应该怎么行文下去的了,然后发之前我把文章又看了几遍,尽可能把断片的地方抹平了,如果大家读起来有什么地方感觉奇怪的,或者讲到一半就没了的,那应该就是断片了。在评论区跟我说一下,我补上去。

然后如果有需要勘误的地方,也请在评论区指出,帮助我把错的地方订正回来,如果有没讲到的地方,但你又特别想要了解的,也可以在评论区提出来,我会补上去。说不定看完之后你脑袋里还会有很多个问号,也请在评论区问出来哈,说不定别人也有跟你一样的问题,他就能在评论区找到答案了。






在第二篇文章的评论区里面出现了喷子,遇到这种情况我怎么可能删帖呢?那根本就不是我的风格哇,哈哈哈。我肯定是会喷回去的,并且还会把链接传播给周围人,发动周围朋友来看:"快看,这儿有2B,哈哈哈"。

嗯,所以评论的时候你一定要想清楚哈,我写代码的实力不差,打嘴仗的实力那可比写代码强多了。评论区同样欢迎切磋。






有任何问题建议直接在评论区提问,这样后来的人如果有相同的问题,就能直接找到答案了。提问之前也可以先看看评论区有没有人问过类似问题了。

所有评论和问题我都会在第一时间回复,QQ上我是不回答问题的哈。




iOS应用架构谈 view层的组织和调用方案

作者 Casa Taloyum
2015年4月24日 00:00




iOS应用架构谈 开篇
iOS应用架构谈 view层的组织和调用方案
iOS应用架构谈 网络层设计方案
iOS应用架构谈 本地持久化方案及动态部署
iOS应用架构谈 组件化方案




前言


iOS应用架构谈 开篇》出来之后,很多人来催我赶紧出第二篇。这一篇文章出得相当艰难,因为公司里的破事儿特别多,我自己又有点私事儿,以至于能用来写博客的时间不够充分。

现在好啦,第二篇出来了。


当我们开始设计View层的架构时,往往是这个App还没有开始开发,或者这个App已经发过几个版本了,然后此时需要做非常彻底的重构。

一般也就是这两种时机会去做View层架构,基于这个时机的特殊性,我们在这时候必须清楚认识到:View层的架构一旦实现或定型,在App发版后可修改的余地就已经非常之小了。因为它跟业务关联最为紧密,所以哪怕稍微动一点点,它所引发的蝴蝶效应都不见得是业务方能够hold住的。这样的情况,就要求我们在实现这个架构时,代码必须得改得勤快,不能偷懒。也必须抱着充分的自我怀疑态度,做决策时要拿捏好尺度。


View层的架构非常之重要,在我看来,这部分架构是这系列文章涉及4个方面最重要的一部分,没有之一。为什么这么说?



View层架构是影响业务方迭代周期的因素之一


产品经理产生需求的速度会非常快,尤其是公司此时仍处于创业初期,在规模稍大的公司里面,产品经理也喜欢挖大坑来在leader面前刷存在感,比如阿里。这就导致业务工程师任务非常繁重。正常情况下让产品经理砍需求是不太可能的,因此作为架构师,在架构里有一些可做可不做的事情,最好还是能做就做掉,不要偷懒。这可以帮业务方减负,编写代码的时候也能更加关注业务。

我跟一些朋友交流的时候,他们都会或多或少地抱怨自己的团队迭代速度不够快,或者说,迭代速度不合理地慢。我认为迭代速度不是想提就能提的,迭代速度的影响因素有很多,一期PRD里的任务量和任务复杂度都会影响迭代周期能达到什么样的程度。抛开这些外在的不谈,从内在可能导致迭代周期达不到合理的速度的原因来看,其中有一个原因很有可能就是View层架构没有做好,让业务工程师完成一个不算复杂的需求时,需要处理太多额外的事情。当然,开会多,工程师水平烂也属于迭代速度提不上去的内部原因,但这个不属于本文讨论范围。还有,加班不是优化迭代周期的正确方式,嗯。

一般来说,一个不够好的View层架构,主要原因有以下五种:

  1. 代码混乱不规范
  2. 过多继承导致的复杂依赖关系
  3. 模块化程度不够高,组件粒度不够细
  4. 横向依赖
  5. 架构设计失去传承


这五个地方会影响业务工程师实现需求的效率,进而拖慢迭代周期。View架构的其他缺陷也会或多或少地产生影响,但在我看来这里五个是比较重要的影响因素。如果大家觉得还有什么因素比这四个更高的,可以在评论区提出来我补上去。

对于第五点我想做一下强调:架构的设计是一定需要有传承的,有传承的架构从整体上看会非常协调。但实际情况有可能是一个人走了,另一个顶上,即便任务交接得再完整,都不可避免不同的人有不同的架构思路,从而导致整个架构的流畅程度受到影响。要解决这个问题,一方面要尽量避免单点问题,让架构师做架构的时候再带一个人。另一方面,架构要设计得尽量简单,平缓接手人的学习曲线。我离开安居客的时候,做过保证:凡是从我手里出来的代码,终身保修。所以不要想着离职了就什么事儿都不管了,这不光是职业素养问题,还有一个是你对你的代码是否足够自信的问题。传承性对于View层架构非常重要,因为它距离业务最近,改动余地最小。

所以当各位CTO、技术总监、TeamLeader们觉得迭代周期不够快时,你可以先不忙着急吼吼地去招新人,《人月神话》早就说过加人不能完全解决问题。这时候如果你可以回过头来看一下是不是View层架构不合理,把这个弄好也是优化迭代周期的手段之一。

嗯,至于本系列其他三项的架构方案对于迭代周期的影响程度,我认为都不如View层架构方案对迭代周期的影响高,所以这是我认为View层架构是最重要的其中一个理由。



View层架构是最贴近业务的底层架构


View层架构虽然也算底层,但还没那么底层,它跟业务的对接面最广,影响业务层代码的程度也最深。在所有的底层都牵一发的时候,在View架构上牵一发导致业务层动全身的面积最大。

所以View架构在所有架构中一旦定型,可修改的空间就最小,我们在一开始考虑View相关架构时,不光要实现功能,还要考虑更多规范上的东西。制定规范的目的一方面是防止业务工程师的代码腐蚀View架构,另一方面也是为了能够有所传承。按照规范来,总还是不那么容易出差池的。

还有就是,架构师一开始考虑的东西也会有很多,不可能在第一版就把它们全部实现,对于一个尚未发版的App来说,第一版架构往往是最小完整功能集,那么在第二版第三版的发展过程中,架构的迭代任务就很有可能不只是你一个人的事情了,相信你一个人也不见得能搞定全部。所以你要跟你的合作者们有所约定。另外,第一版出去之后,业务工程师在使用过程中也会产生很多修改意见,哪些意见是合理的,哪些意见是不合理的,也要通过事先约定的规范来进行筛选,最终决定如何采纳。

规范也不是一成不变的,什么时候枪毙意见,什么时候改规范,这就要靠各位的技术和经验了。


以上就是前言。




这篇文章讲什么?


  • View代码结构的规定

  • 关于view的布局

  • 何时使用storyboard,何时使用nib,何时使用代码写View

  • 是否有必要让业务方统一派生ViewController?

  • 方便View布局的小工具

  • MVC、MVVM、MVCS、VIPER

  • 本门心法

  • 跨业务时View的处理

  • 留给评论区各种补

  • 总结




View代码结构的规定


架构师不是写SDK出来交付业务方使用就没事儿了的,每家公司一定都有一套代码规范,架构师的职责也包括定义代码规范。按照道理来讲,定代码规范应该是属于通识,放在这里讲的原因只是因为我这边需要为View添加一个规范。

制定代码规范严格来讲不属于View层架构的事情,但它对View层架构未来的影响会比较大,也是属于架构师在设计View层架构时需要考虑的事情。制定View层规范的重要性在于:

  1. 提高业务方View层的可读性可维护性
  2. 防止业务代码对架构产生腐蚀
  3. 确保传承
  4. 保持架构发展的方向不轻易被不合理的意见所左右


在这一节里面我不打算从头开始定义一套规范,苹果有一套Coding Guidelines,当我们定代码结构或规范的时候,首先一定要符合这个规范。

然后,相信大家各自公司里面也都有一套自己的规范,具体怎么个规范法其实也是根据各位架构师的经验而定,我这边只是建议各位在各自规范的基础上再加上下面这一点。


viewController的代码应该差不多是这样:

pic1



要点如下:



所有的属性都使用getter和setter


不要在viewDidLoad里面初始化你的view然后再add,这样代码就很难看。在viewDidload里面只做addSubview的事情,然后在viewWillAppear里面做布局的事情(勘误1),最后在viewDidAppear里面做Notification的监听之类的事情。至于属性的初始化,则交给getter去做。

比如这样:

#pragma mark - life cycle
- (void)viewDidLoad
{
    [super viewDidLoad];

    self.view.backgroundColor = [UIColor whiteColor];
    [self.view addSubview:self.firstTableView];
    [self.view addSubview:self.secondTableView];
    [self.view addSubview:self.firstFilterLabel];
    [self.view addSubview:self.secondFilterLabel];
    [self.view addSubview:self.cleanButton];
    [self.view addSubview:self.originImageView];
    [self.view addSubview:self.processedImageView];
    [self.view addSubview:self.activityIndicator];
    [self.view addSubview:self.takeImageButton];
}

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];

    CGFloat width = (self.view.width - 30) / 2.0f;

    self.originImageView.size = CGSizeMake(width, width);
    [self.originImageView topInContainer:70 shouldResize:NO];
    [self.originImageView leftInContainer:10 shouldResize:NO];

    self.processedImageView.size = CGSizeMake(width, width);
    [self.processedImageView right:10 FromView:self.originImageView];
    [self.processedImageView topEqualToView:self.originImageView];

    CGFloat labelWidth = self.view.width - 100;
    self.firstFilterLabel.size = CGSizeMake(labelWidth, 20);
    [self.firstFilterLabel leftInContainer:10 shouldResize:NO];
    [self.firstFilterLabel top:10 FromView:self.originImageView];

    ... ...
}


这样即便在属性非常多的情况下,还是能够保持代码整齐,view的初始化都交给getter去做了。总之就是尽量不要出现以下的情况:


- (void)viewDidLoad
{
    [super viewDidLoad];

    self.textLabel = [[UILabel alloc] init];
    self.textLabel.textColor = [UIColor blackColor];
    self.textLabel ... ...
    self.textLabel ... ...
    self.textLabel ... ...
    [self.view addSubview:self.textLabel];
}

这种做法就不够干净,都扔到getter里面去就好了。关于这个做法,在唐巧的技术博客里面有一篇文章和我所提倡的做法不同,这个我会放在后面详细论述。



getter和setter全部都放在最后


因为一个ViewController很有可能会有非常多的view,就像上面给出的代码样例一样,如果getter和setter写在前面,就会把主要逻辑扯到后面去,其他人看的时候就要先划过一长串getter和setter,这样不太好。然后要求业务工程师写代码的时候按照顺序来分配代码块的位置,先是life cycle,然后是Delegate方法实现,然后是event response,然后才是getters and setters。这样后来者阅读代码时就能省力很多。



每一个delegate都把对应的protocol名字带上,delegate方法不要到处乱写,写到一块区域里面去


比如UITableViewDelegate的方法集就老老实实写上#pragma mark - UITableViewDelegate。这样有个好处就是,当其他人阅读一个他并不熟悉的Delegate实现方法时,他只要按住command然后去点这个protocol名字,Xcode就能够立刻跳转到对应这个Delegate的protocol定义的那部分代码去,就省得他到处找了。



event response专门开一个代码区域


所有button、gestureRecognizer的响应事件都放在这个区域里面,不要到处乱放。



关于private methods,正常情况下ViewController里面不应该写


不是delegate方法的,不是event response方法的,不是life cycle方法的,就是private method了。对的,正常情况下ViewController里面一般是不会存在private methods的,这个private methods一般是用于日期换算、图片裁剪啥的这种小功能。这种小功能要么把它写成一个category,要么把他做成一个模块,哪怕这个模块只有一个函数也行。

ViewController基本上是大部分业务的载体,本身代码已经相当复杂,所以跟业务关联不大的东西能不放在ViewController里面就不要放。另外一点,这个private method的功能这时候只是你用得到,但是将来说不定别的地方也会用到,一开始就独立出来,有利于将来的代码复用。



为什么要这样要求?


我见过无数ViewController,代码布局乱得一塌糊涂,这里一个delegate那里一个getter,然后ViewController的代码一般都死长死长的,看了就让人头疼。

定义好这个规范,就能使得ViewController条理清晰,业务方程序员很能够区分哪些放在ViewController里面比较合适,哪些不合适。另外,也可以提高代码的可维护性和可读性。




关于View的布局


业务工程师在写View的时候一定逃不掉的就是这个命题。用Frame也好用Autolayout也好,如果没有精心设计过,布局部分一定惨不忍睹。

直接使用CGRectMake的话可读性很差,光看那几个数字,也无法知道view和view之间的位置关系。用Autolayout可读性稍微好点儿,但生成Constraint的长度实在太长,代码观感不太好。

Autolayout这边可以考虑使用Masonry,代码的可读性就能好很多。如果还有使用Frame的,可以考虑一下使用这个项目

这个项目里面提供了Frame相关的方便方法(UIView+LayoutMethods),里面的方法也基本涵盖了所有布局的需求,可读性非常好,使用它之后基本可以和CGRectMake说再见了。因为天猫在最近才切换到支持iOS6,所以之前天猫都是用Frame布局的,在天猫App中,首页,范儿部分页面的布局就使用了这些方法。使用这些方便方法能起到事半功倍的效果。

这个项目也提供了Autolayout方案下生产Constraints的方便方法(UIView+AEBHandyAutoLayout),可读性比原生好很多。我当时在写这系列方法的时候还不知道有Masonry。知道有Masonry之后我特地去看了一下,发现Masonry功能果然强大。不过这系列方法虽然没有Masonry那么强大,但是也够用了。当时安居客iPad版App全部都是Autolayout来做的View布局,就是使用的这个项目里面的方法。可读性很好。

让业务工程师使用良好的工具来做View的布局,能提高他们的工作效率,也能减少bug发生的几率。架构师不光要关心那些高大上的内容,也要多给业务工程师提供方便易用的小工具,才能发挥架构师的价值。




何时使用storyboard,何时使用nib,何时使用代码写View


这个问题唐巧的博客里这篇文章也提到过,我的意见和他是基本一致的。


在这里我还想补充一些内容:

具有一定规模的团队化iOS开发(10人以上)有以下几个特点:

  1. 同一份代码文件的作者会有很多,不同作者同时修改同一份代码的情况也不少见。因此,使用Git进行代码版本管理时出现Conflict的几率也比较大。
  2. 需求变化非常频繁,产品经理一时一个主意,为了完成需求而针对现有代码进行微调的情况,以及针对现有代码的部分复用的情况也比较多。
  3. 复杂界面元素、复杂动画场景的开发任务比较多。


如果这三个特点你一看就明白了,下面的解释就可以不用看了。如果你针对我的倾向愿意进一步讨论的,可以先看我下面的解释,看完再说。



同一份代码文件的作者会有很多,不同作者同时修改同一份代码的情况也不少见。因此,使用Git进行代码版本管理时出现Conflict的几率也比较大。


iOS开发过程中,会遇到最蛋疼的两种Conflict一个是project.pbxproj,另外一个就是StoryBoardXIB。因为这些文件的内容的可读性非常差,虽然苹果在XCode5(现在我有点不确定是不是这个版本了)中对StoryBoard的文件描述方式做了一定的优化,但只是把可读性从非常差提升为很差

然而在StoryBoard中往往包含了多个页面,这些页面基本上不太可能都由一个人去完成,如果另一个人在做StoryBoard的操作的时候,出于某些目的动了一下不属于他的那个页面,比如为了美观调整了一下位置。然后另外一个人也因为要添加一个页面,而在Storyboard中调整了一下某个其他页面的位置。那么针对这个情况我除了说个呵呵以外,我就只能说:祝你好运。看清楚哦,这还没动具体的页页面内容呢。

但如果使用代码绘制View,Conflict一样会发生,但是这种Conflict就好解很多了,你懂的。



需求变化非常频繁,产品经理一时一个主意,为了完成需求而针对现有代码进行微调的情况,以及针对现有代码的部分复用的情况也比较多。


我觉得产品经理一时一个主意不是他的错,他说不定也是被逼的,比如谁都会来掺和一下产品的设计,公司里的所有人,上至CEO,下至基层员工都有可能对产品设计评头论足,只要他个人有个地方用得不爽(极大可能是个人喜好)然后又正好跟产品经理比较熟悉能够搭得上话,都会提出各种意见。产品经理躲不起也惹不起,有时也是没办法,嗯。

但落实到工程师这边来,这种情况就很蛋疼。因为这种改变有时候不光是UI,UI所对应的逻辑也有要改的可能,工程师就会两边文件都改,你原来link的那个view现在不link了,然后你的outlet对应也要删掉,这两部分只要有一个没做,编译通过之后跑一下App,一会儿就crash了。看起来这不是什么大事儿,但很影响心情。

另外,如果出现部分的代码复用,比如说某页面下某个View也希望放在另外一个页面里,相关的操作就不是复制粘贴这么简单了,你还得重新link一遍。也很影响心情。



复杂界面元素,复杂动画交互场景的开发任务比较多。


要是想在基于StoryBoard的项目中做一个动画,很烦。做几个复杂界面元素,也很烦。有的时候我们挂Custom View上去,其实在StoryBoard里面看来就是一个空白View。然后另外一点就是,当你的layout出现问题需要调整的时候,还是挺难找到问题所在的,尤其是在复杂界面元素的情况下。

所以在针对View层这边的要求时,我也是建议不要用StoryBoard。实现简单的东西,用Code一样简单,实现复杂的东西,Code比StoryBoard更简单。所以我更加提倡用code去画view而不是storyboard。




是否有必要让业务方统一派生ViewController


有的时候我们出于记录用户操作行为数据的需要,或者统一配置页面的目的,会从UIViewController里面派生一个自己的ViewController,来执行一些通用逻辑。比如天猫客户端要求所有的ViewController都要继承自TMViewController。这个统一的父类里面针对一个ViewController的所有生命周期都做了一些设置,至于这里都有哪些设置对于本篇文章来说并不重要。在这里我想讨论的是,在设计View架构时,如果为了能够达到统一设置或执行统一逻辑的目的,使用派生的手段是有必要的吗?

我觉得没有必要,为什么没有必要?


  1. 使用派生比不使用派生更容易增加业务方的使用成本
  2. 不使用派生手段一样也能达到统一设置的目的


这两条原因是我认为没有必要使用派生手段的理由,如果两条理由你都心领神会,那么下面的就可以不用看了。如果你还有点疑惑,请看下面我来详细讲一下原因。



为什么使用了派生,业务方的使用成本会提升?


其实不光是业务方的使用成本,架构的维护成本也会上升。那么具体的成本都来自于哪里呢?


  • 集成成本


这里讲的集成成本是这样的:如果业务方自己开了一个独立demo,快速完成了某个独立流程,现在他想把这个现有流程集合进去。那么问题就来了,他需要把所有独立的UIViewController改变成TMViewController。那为什么不是一开始就立刻使用TMViewController呢?因为要想引入TMViewController,就要引入整个天猫App所有的业务线,所有的基础库,因为这个父类里面涉及很多天猫环境才有的内容,所谓拔出萝卜带出泥,你要是想简单继承一下就能搞定的事情,搭环境就要搞半天,然后这个小Demo才能跑得起来。

对于业务层存在的所有父类来说,它们是很容易跟项目中的其他代码纠缠不清的,这使得业务方开发时遇到一个两难问题:要么把所有依赖全部搞定,然后基于App环境(比如天猫)下开发Demo要么就是自己Demo写好之后,按照环境要求改代码。这里的两难问题都会带来成本,都会影响业务方的迭代进度。

我不确定各位所在公司是否会有这样的情况,但我可以在这里给大家举一个我在阿里的真实的例子:我最近在开发某滤镜Demo和相关页面流程,最终是要合并到天猫这个App里面去的。使用天猫环境进行开发的话,pod install完所有依赖差不多需要10分钟,然后打开workspace之后,差不多要再等待1分钟让xcode做好索引,然后才能正式开始工作。在这里要感谢一下则平,因为他在此基础上做了很多优化,使得这个1分钟已经比原来的时间短很多了。但如果天猫环境有更新,你就要再重复一次上面的流程,否则 就很有可能编译不过。

拜托,我只是想做个Demo而已,不想搞那么复杂。


  • 上手接受成本


新来的业务工程师有的时候不见得都记得每一个ViewController都必须要派生自TMViewController而不是直接的UIViewController。新来的工程师他不能直接按照苹果原生的做法去做事情,他需要额外学习,比如说:所有的ViewController都必须继承自TMViewController。


  • 架构的维护难度


尽可能少地使用继承能提高项目的可维护性,具体内容我在《跳出面向对象思想(一) 继承》里面说了,在这里我想偷懒不想把那篇文章里说过的东西再说一遍。

其实对于业务方来说,主要还是第一个集成成本比较蛋疼,因为这是长痛,每次要做点什么事情都会遇到。第二点倒还好,短痛。第三点跟业务工程师没啥关系。



那么如果不使用派生,我们应该使用什么手段?


我的建议是使用AOP。

在架构师实现具体的方案之前,必须要想清楚几个问题,然后才能决定采用哪种方案。是哪几个问题?


  1. 方案的效果,和最终要达到的目的是什么?
  2. 在自己的知识体系里面,是否具备实现这个方案的能力?
  3. 在业界已有的开源组件里面,是否有可以直接拿来用的轮子?


这三个问题按照顺序一一解答之后,具体方案就能出来了。




我们先看第一个问题:方案的效果,和最终要达到的目的是什么?


方案的效果应该是:


  1. 业务方可以不用通过继承的方法,然后框架能够做到对ViewController的统一配置。
  2. 业务方即使脱离框架环境,不需要修改任何代码也能够跑完代码。业务方的ViewController一旦丢入框架环境,不需要修改任何代码,框架就能够起到它应该起的作用。


其实就是要实现不通过业务代码上对框架的主动迎合,使得业务能够被框架感知这样的功能。细化下来就是两个问题,框架要能够拦截到ViewController的生命周期,另一个问题就是,拦截的定义时机。

对于方法拦截,很容易想到Method Swizzling,那么我们可以写一个实例,在App启动的时候添加针对UIViewController的方法拦截,这是一种做法。还有另一种做法就是,使用NSObject的load函数,在应用启动时自动监听。使用后者的好处在于,这个模块只要被项目包含,就能够发挥作用,不需要在项目里面添加任何代码。


然后另外一个要考虑的事情就是,原有的TMViewController(所谓的父类)也是会提供额外方法方便子类使用的,Method Swizzling只支持针对现有方法的操作,拓展方法的话,嗯,当然是用Category啦。

我本人不赞成Category的过度使用,但鉴于Category是最典型的化继承为组合的手段,在这个场景下还是适合使用的。还有的就是,关于Method Swizzling手段实现方法拦截,业界也已经有了现成的开源库:Aspects,我们可以直接拿来使用。


我这边有个非常非常小的Demo可以放出来给大家,这个Demo只是一个点睛之笔,有一些话我也写在这个Demo里面了,各位架构师们你们可以基于各自公司App的需求去拓展。

这个Demo不包含Category,毕竟Category还是得你们自己去写啊~然后这套方案能够完成原来通过派生手段所有可以完成的任务,但同时又允许业务方不必添加任何代码,直接使用原生的UIViewController。


然后另外要提醒的是,这方案的目的是消除不必要的继承,虽然不限定于UIViewController,但它也是有适用范围的,在适用继承的地方,还是要老老实实使用继承。比如你有一个数据模型,是由基本模型派生出的一整套模型,那么这个时候还是老老实实使用继承。至于拿捏何时使用继承,相信各位架构师一定能够处理好,或者你也可以参考我前面提到的那篇文章来控制拿捏的尺度。




关于MVC、MVVM等一大堆思想


其实这些都是相对通用的思想,万变不离其宗的还是在开篇里面我提到的那三个角色:数据管理者数据加工者数据展示者。这些五花八门的思想,不外乎就是制订了一个规范,规定了这三个角色应当如何进行数据交换。但同时这些也是争议最多的话题,所以我在这里来把几个主流思想做一个梳理,当你在做View层架构时,能够有个比较好的参考。



MVC


MVC(Model-View-Controller)是最老牌的的思想,老牌到4人帮的书里把它归成了一种模式,其中Model就是作为数据管理者View作为数据展示者Controller作为数据加工者ModelView又都是由Controller来根据业务需求调配,所以Controller还负担了一个数据流调配的功能。正在我写这篇文章的时候,我看到InfoQ发了这篇文章,里面提到了一个移动开发中的痛点是:对MVC架构划分的理解。我当时没能够去参加这个座谈会,也没办法发表个人意见,所以就只能在这里写写了。



在iOS开发领域,我们应当如何进行MVC的划分?


这里面其实有两个问题:


  1. 为什么我们会纠结于iOS开发领域中MVC的划分问题?
  2. 在iOS开发领域中,怎样才算是划分的正确姿势?



为什么我们会纠结于iOS开发领域中MVC的划分问题?


关于这个,每个人纠结的点可能不太一样,我也不知道当时座谈会上大家的观点。但请允许我猜一下:是不是因为UIViewController中自带了一个View,且控制了View的整个生命周期(viewDidLoad,viewWillAppear...),而在常识中我们都知道Controller不应该和View有如此紧密的联系,所以才导致大家对划分产生困惑?,下面我会针对这个猜测来给出我的意见。


在服务端开发领域,Controller和View的交互方式一般都是这样,比如Yii:



    /*
        ...
            数据库取数据
        ...
            处理数据
        ...
    */

    // 此处$this就是Controller
    $this->render("plan",array(
        'planList' => $planList,
        'plan_id' => $_GET['id'],
    ));



这里Controller和View之间区分得非常明显,Controller做完自己的事情之后,就把所有关于View的工作交给了页面渲染引擎去做,Controller不会去做任何关于View的事情,包括生成View,这些都由渲染引擎代劳了。这是一个区别,但其实服务端View的概念和Native应用View的概念,真正的区别在于:从概念上严格划分的话,服务端其实根本没有View,拜HTTP协议所赐,我们平时所讨论的View只是用于描述View的字符串(更实质的应该称之为数据),真正的View是浏览器。

所以服务端只管生成对View的描述,至于对View的长相,UI事件监听和处理,都是浏览器负责生成和维护的。但是在Native这边来看,原本属于浏览器的任务也逃不掉要自己做。那么这件事情由谁来做最合适?苹果给出的答案是:UIViewController

鉴于苹果在这一层做了很多艰苦卓绝的努力,让iOS工程师们不必亲自去实现这些内容。而且,它把所有的功能都放在了UIView上,并且把UIView做成不光可以展示UI,还可以作为容器的一个对象。

看到这儿你明白了吗?UIView的另一个身份其实是容器!UIViewController中自带的那个view,它的主要任务就是作为一个容器。如果它所有的相关命名都改成ViewContainer,那么代码就会变成这样:



- (void)viewContainerDidLoad
{
    [self.viewContainer addSubview:self.label];
    [self.viewContainer addSubview:self.tableView];
    [self.viewContainer addSubview:self.button];
    [self.viewContainer addSubview:self.textField];
}

... ...



仅仅改了个名字,现在是不是感觉清晰了很多?如果再要说详细一点,我们平常所认为的服务端MVC是这样划分的:



               ---------------------------
               | C                       |
               |        Controller       |
               |                         |
               ---------------------------
              /                           \
             /                             \
            /                               \
------------                                 ---------------------
| M        |                                 | V                 |
|   Model  |                                 |    Render Engine  |
|          |                                 |          +        |
------------                                 |      HTML Files   |
                                             ---------------------



但事实上,整套流程的MVC划分是这样:



               ---------------------------
               | C                       |
               |   Controller            |
               |           \             |
               |           Render Engine |
               |                 +       |
               |             HTML Files  |
               ---------------------------
              /                           \
             /                             \ HTML String
            /                               \
------------                                 ---------------
| M        |                                 | V           |
|   Model  |                                 |    Browser  |
|          |                                 |             |
------------                                 ---------------



由图中可以看出,我们服务端开发在这个概念下,其实只涉及M和C的开发工作,浏览器作为View的容器,负责View的展示和事件的监听。那么对应到iOS客户端的MVC划分上面来,就是这样:



               ----------------------------
               | C                        |
               |   Controller             |
               |           \              |
               |           View Container |
               ----------------------------
              /                            \
             /                              \
            /                                \
------------                                  ----------------------
| M        |                                  | V                  |
|   Model  |                                  |    UITableView     |
|          |                                  |    YourCustomView  |
------------                                  |         ...        |
                                              ----------------------



唯一区别在于,View的容器在服务端,是由Browser负责,在整个网站的流程中,这个容器放在Browser是非常合理的。在iOS客户端,View的容器是由UIViewController中的view负责,我也觉得苹果做的这个选择是非常正确明智的。

因为浏览器和服务端之间的关系非常松散,而且他们分属于两个不同阵营,服务端将对View的描述生成之后,交给浏览器去负责展示,然而一旦view上有什么事件产生,基本上是很少传递到服务器(也就是所谓的Controller)的(要传也可以:AJAX),都是在浏览器这边把事情都做掉,所以在这种情况下,View容器就适合放在浏览器(V)这边。

但是在iOS开发领域,虽然也有让View去监听事件的做法,但这种做法非常少,都是把事件回传给Controller,然后Controller再另行调度。所以这时候,View的容器放在Controller就非常合适。Controller可以因为不同事件的产生去很方便地更改容器内容,比如加载失败时,把容器内容换成失败页面的View,无网络时,把容器页面换成无网络的View等等。



在iOS开发领域中,怎样才算是MVC划分的正确姿势?


这个问题其实在上面已经解答掉一部分了,那么这个问题的答案就当是对上面问题的一个总结吧。



M应该做的事:


  1. 给ViewController提供数据
  2. 给ViewController存储数据提供接口
  3. 提供经过抽象的业务基本组件,供Controller调度



C应该做的事:


  1. 管理View Container的生命周期
  2. 负责生成所有的View实例,并放入View Container
  3. 监听来自View与业务有关的事件,通过与Model的合作,来完成对应事件的业务。



V应该做的事:


  1. 响应与业务无关的事件,并因此引发动画效果,点击反馈(如果合适的话,尽量还是放在View去做)等。
  2. 界面元素表达





我通过与服务端MVC划分的对比来回答了这两个问题,之所以这么做,是因为我知道有很多iOS工程师之前是从服务端转过来的。我也是这样,在进安居客之前,我也是做服务端开发的,在学习iOS的过程中,我也曾经对iOS领域的MVC划分问题产生过疑惑,我疑惑的点就是前面开篇我猜测的点。如果有人问我iOS中应该怎么做MVC的划分,我就会像上面这么回答。




MVCS


苹果自身就采用的是这种架构思路,从名字也能看出,也是基于MVC衍生出来的一套架构。从概念上来说,它拆分的部分是Model部分,拆出来一个Store。这个Store专门负责数据存取。但从实际操作的角度上讲,它拆开的是Controller。


这算是瘦Model的一种方案,瘦Model只是专门用于表达数据,然后存储、数据处理都交给外面的来做。MVCS使用的前提是,它假设了你是瘦Model,同时数据的存储和处理都在Controller去做。所以对应到MVCS,它在一开始就是拆分的Controller。因为Controller做了数据存储的事情,就会变得非常庞大,那么就把Controller专门负责存取数据的那部分抽离出来,交给另一个对象去做,这个对象就是Store。这么调整之后,整个结构也就变成了真正意义上的MVCS。



关于胖Model和瘦Model


我在面试和跟别人聊天时,发现知道胖Model和瘦Model的概念的人不是很多。大约两三年前国外业界曾经对此有过非常激烈的讨论,主题就是Fat model, skinny controller。现在关于这方面的讨论已经不多了,然而直到今天胖Model和瘦Model哪个更好,业界也还没有定论,所以这算是目前业界悬而未解的一个争议。我很少看到国内有讨论这个的资料,所以在这里我打算补充一下什么叫胖Model什么叫瘦Model。以及他们的争论来源于何处。



  • 什么叫胖Model?


胖Model包含了部分弱业务逻辑。胖Model要达到的目的是,Controller从胖Model这里拿到数据之后,不用额外做操作或者只要做非常少的操作,就能够将数据直接应用在View上。举个例子:



Raw Data:
    timestamp:1234567

FatModel:
    @property (nonatomic, assign) CGFloat timestamp;
    - (NSString *)ymdDateString; // 2015-04-20 15:16
    - (NSString *)gapString; // 3分钟前、1小时前、一天前、2015-3-13 12:34

Controller:
    self.dateLabel.text = [FatModel ymdDateString];
    self.gapLabel.text = [FatModel gapString];



把timestamp转换成具体业务上所需要的字符串,这属于业务代码,算是弱业务。FatModel做了这些弱业务之后,Controller就能变得非常skinny,Controller只需要关注强业务代码就行了。众所周知,强业务变动的可能性要比弱业务大得多,弱业务相对稳定,所以弱业务塞进Model里面是没问题的。另一方面,弱业务重复出现的频率要大于强业务,对复用性的要求更高,如果这部分业务写在Controller,类似的代码会洒得到处都是,一旦弱业务有修改(弱业务修改频率低不代表就没有修改),这个事情就是一个灾难。如果塞到Model里面去,改一处很多地方就能跟着改,就能避免这场灾难。

然而其缺点就在于,胖Model相对比较难移植,虽然只是包含弱业务,但好歹也是业务,迁移的时候很容易拔出萝卜带出泥。另外一点,MVC的架构思想更加倾向于Model是一个Layer,而不是一个Object,不应该把一个Layer应该做的事情交给一个Object去做。最后一点,软件是会成长的,FatModel很有可能随着软件的成长越来越Fat,最终难以维护。



  • 什么叫瘦Model?


瘦Model只负责业务数据的表达,所有业务无论强弱一律扔到Controller。瘦Model要达到的目的是,尽一切可能去编写细粒度Model,然后配套各种helper类或方法来对弱业务做抽象,强业务依旧交给Controller。举个例子:



Raw Data:
{
    "name":"casa",
    "sex":"male",
}

SlimModel:
    @property (nonatomic, strong) NSString *name;
    @property (nonatomic, strong) NSString *sex;

Helper:
    #define Male 1;
    #define Female 0;
    + (BOOL)sexWithString:(NSString *)sex;

Controller:
    if ([Helper sexWithString:SlimModel.sex] == Male) {
        ...
    }



由于SlimModel跟业务完全无关,它的数据可以交给任何一个能处理它数据的Helper或其他的对象,来完成业务。在代码迁移的时候独立性很强,很少会出现拔出萝卜带出泥的情况。另外,由于SlimModel只是数据表达,对它进行维护基本上是0成本,软件膨胀得再厉害,SlimModel也不会大到哪儿去。

缺点就在于,Helper这种做法也不见得很好,这里有一篇文章批判了这个事情。另外,由于Model的操作会出现在各种地方,SlimModel在一定程度上违背了DRY(Don't Repeat Yourself)的思路,Controller仍然不可避免在一定程度上出现代码膨胀。

我的态度?嗯,我会在本门心法这一节里面说。

说回来,MVCS是基于瘦Model的一种架构思路,把原本Model要做的很多事情中的其中一部分关于数据存储的代码抽象成了Store,在一定程度上降低了Controller的压力。



MVVM


MVVM去年在业界讨论得非常多,无论国内还是国外都讨论得非常热烈,尤其是在ReactiveCocoa这个库成熟之后,ViewModel和View的信号机制在iOS下终于有了一个相对优雅的实现。MVVM本质上也是从MVC中派生出来的思想,MVVM着重想要解决的问题是尽可能地减少Controller的任务。不管MVVM也好,MVCS也好,他们的共识都是Controller会随着软件的成长,变很大很难维护很难测试。只不过两种架构思路的前提不同,MVCS是认为Controller做了一部分Model的事情,要把它拆出来变成Store,MVVM是认为Controller做了太多数据加工的事情,所以MVVM把数据加工的任务从Controller中解放了出来,使得Controller只需要专注于数据调配的工作,ViewModel则去负责数据加工并通过通知机制让View响应ViewModel的改变。

MVVM是基于胖Model的架构思路建立的,然后在胖Model中拆出两部分:Model和ViewModel。关于这个观点我要做一个额外解释:胖Model做的事情是先为Controller减负,然后由于Model变胖,再在此基础上拆出ViewModel,跟业界普遍认知的MVVM本质上是为Controller减负这个说法并不矛盾,因为胖Model做的事情也是为Controller减负。

另外,我前面说MVVM把数据加工的任务从Controller中解放出来,跟MVVM拆分的是胖Model也不矛盾。要做到解放Controller,首先你得有个胖Model,然后再把这个胖Model拆成Model和ViewModel。



那么MVVM究竟应该如何实现?


这很有可能是大多数人纠结的问题,我打算凭我的个人经验试图在这里回答这个问题,欢迎大家在评论区交流。

在iOS领域大部分MVVM架构都会使用ReactiveCocoa,但是使用ReactiveCocoa的iOS应用就是基于MVVM架构的吗?那当然不是,我觉得很多人都存在这个误区,我面试过的一些人提到了ReactiveCocoa也提到了MVVM,但他们对此的理解肤浅得让我忍俊不禁。嗯,在网络层架构我会举出不使用ReactiveCocoa的例子,现在举我感觉有点儿早。



MVVM的关键是要有View Model!而不是ReactiveCocoa(勘误2)


ViewModel做什么事情?就是把RawData变成直接能被View使用的对象的一种Model。举个例子:



    Raw Data:
        {
            (
                (123, 456),
                (234, 567),
                (345, 678)
            )
        }



这里的RawData我们假设是经纬度,数字我随便写的不要太在意。然后你有一个模块是地图模块,把经纬度数组全部都转变成MKAnnotation或其派生类对于Controller来说是弱业务,(记住,胖Model就是用来做弱业务的),因此我们用ViewModel直接把它转变成MKAnnotation的NSArray,交给Controller之后Controller直接就可以用了。


嗯,这就是ViewModel要做的事情,是不是觉得很简单,看不出优越性?


安居客Pad应用也有一个地图模块,在这里我设计了一个对象叫做reformer(其实就是ViewModel),专门用来干这个事情。那么这么做的优越性体现在哪儿呢?

安居客分三大业务:租房、二手房、新房。这三个业务对应移动开发团队有三个API开发团队,他们各自为政,这就造成了一个结果:三个API团队回馈给移动客户端的数据内容虽然一致,但是数据格式是不一致的,也就是相同value对应的key是不一致的。但展示地图的ViewController不可能写三个,所以肯定少不了要有一个API数据兼容的逻辑,这个逻辑我就放在reformer里面去做了,于是业务流程就变成了这样:



            用户进入地图页发起地图API请求
                          |
                          |
                          |
      -----------------------------------------
      |                   |                   |
      |                   |                   |
   新房API            二手房API            租房API
      |                   |                   |
      |                   |                   |
      -----------------------------------------
                          |
                          |
                          |
                   获得原始地图数据
                          |
                          |
                          |
     [APIManager fetchDataWithReformer:reformer]
                          |
                          |
                          |
                  MKAnnotationList
                          |
                          |
                          |
                     Controller



这么一来,原本复杂的MKAnnotation组装逻辑就从Controller里面拆分了出来,Controller可以直接拿着Reformer返回的数据进行展示。APIManager就属于Model,reformer就属于ViewModel。具体关于reformer的东西我会放在网络层架构来详细解释。Reformer此时扮演的ViewModel角色能够很好地给Controller减负,同时,维护成本也大大降低,经过reformer产出的永远都是MKAnnotation,Controller可以直接拿来使用。

然后另外一点,还有一个业务需求是取附近的房源,地图API请求是能够hold住这个需求的,那么其他地方都不用变,在fetchDataWithReformer的时候换一个reformer就可以了,其他的事情都交给reformer。



那么ReactiveCocoa应该扮演什么角色?


不用ReactiveCocoa也能MVVM,用ReactiveCocoa能更好地体现MVVM的精髓。前面我举到的例子只是数据从API到View的方向,View的操作也会产生"数据",只不过这里的"数据"更多的是体现在表达用户的操作上,比如输入了什么内容,那么数据就是text、选择了哪个cell,那么数据就是indexPath。那么在数据从view走向API或者Controller的方向上,就是ReactiveCocoa发挥的地方。

我们知道,ViewModel本质上算是Model层(因为是胖Model里面分出来的一部分),所以View并不适合直接持有ViewModel,那么View一旦产生数据了怎么办?扔信号扔给ViewModel,用谁扔?ReactiveCocoa。

在MVVM中使用ReactiveCocoa的第一个目的就是如上所说,View并不适合直接持有ViewModel。第二个目的就在于,ViewModel有可能并不是只服务于特定的一个View,使用更加松散的绑定关系能够降低ViewModel和View之间的耦合度。



那么在MVVM中,Controller扮演什么角色?


大部分国内外资料阐述MVVM的时候都是这样排布的:View <-> ViewModel <-> Model,造成了MVVM不需要Controller的错觉,现在似乎发展成业界开始出现MVVM是不需要Controller的。的声音了。其实MVVM是一定需要Controller的参与的,虽然MVVM在一定程度上弱化了Controller的存在感,并且给Controller做了减负瘦身(这也是MVVM的主要目的)。但是,这并不代表MVVM中不需要Controller,MMVC和MVVM他们之间的关系应该是这样:

MVMCV(来源:http://www.sprynthesis.com/2014/12/06/reactivecocoa-mvvm-introduction/)

View <-> C <-> ViewModel <-> Model,所以使用MVVM之后,就不需要Controller的说法是不正确的。严格来说MVVM其实是MVCVM。从图中可以得知,Controller夹在View和ViewModel之间做的其中一个主要事情就是将View和ViewModel进行绑定。在逻辑上,Controller知道应当展示哪个View,Controller也知道应当使用哪个ViewModel,然而View和ViewModel它们之间是互相不知道的,所以Controller就负责控制他们的绑定关系,所以叫Controller/控制器就是这个原因。


前面扯了那么多,其实归根结底就是一句话:在MVC的基础上,把C拆出一个ViewModel专门负责数据处理的事情,就是MVVM。然后,为了让View和ViewModel之间能够有比较松散的绑定关系,于是我们使用ReactiveCocoa,因为苹果本身并没有提供一个比较适合这种情况的绑定方法。iOS领域里KVO,Notification,block,delegate和target-action都可以用来做数据通信,从而来实现绑定,但都不如ReactiveCocoa提供的RACSignal来的优雅,如果不用ReactiveCocoa,绑定关系可能就做不到那么松散那么好,但并不影响它还是MVVM。

在实际iOS应用架构中,MVVM应该出现在了大部分创业公司或者老牌公司新App的iOS应用架构图中,据我所知易宝支付旗下的某个iOS应用就整体采用了MVVM架构,他们抽出了一个Action层来装各种ViewModel,也是属于相对合理的结构。

所以Controller在MVVM中,一方面负责View和ViewModel之间的绑定,另一方面也负责常规的UI逻辑处理。




VIPER


VIPER(View,Interactor,Presenter,Entity,Routing)。VIPER我并没有实际使用过,我是在objc.io上第13期看到的。

但凡出现一个新架构或者我之前并不熟悉的新架构,有一点我能够非常肯定,这货一定又是把MVC的哪个部分给拆开了(坏笑,做这种判断的理论依据在第一篇文章里面我已经讲过了)。事实情况是VIPER确实拆了很多很多,除了View没拆,其它的都拆了。

我提到的这两篇文章关于VIPER都讲得很详细,一看就懂。但具体在使用VIPER的时候会有什么坑或者会有哪些争议我不是很清楚,硬要写这一节的话我只能靠YY,所以我想想还是算了。如果各位读者有谁在实际App中采用VIPER架构的或者对VIPER很有兴趣的,可以评论区里面提出来,我们交流一下。




本门心法



重剑无锋,大巧不工。 ---- 《神雕侠侣》


这是杨过在挑剑时,玄铁重剑旁边写的一段话。对此我深表认同。提到这段话的目的是想告诉大家,在具体做View层架构的设计时,不需要拘泥于MVC、MVVM、VIPER等规矩。这些都是招式,告诉你你就知道了,然后怎么玩都可以。但是心法不是这样的,心法是大巧,说出来很简单,但是能不能在实际架构设计时牢记心法,并且按照规矩办事,就都看个人了。



拆分的心法


天下功夫出少林,天下架构出MVC。 ---- Casa Taloyum


MVC其实是非常高Level的抽象,意思也就是,在MVC体系下还可以再衍生无数的架构方式,但万变不离其宗的是,它一定符合MVC的规范。这句话不是我说的,是我在某个英文资料上看到的,但时过境迁,我已经找不到出处了,我很赞同这句话。我采用的架构严格来说也是MVC,但也做了很多的拆分。根据前面几节的洗礼,相信各位也明白了这样的道理:拆分方式的不同诞生了各种不同的衍生架构方案(MVCS拆胖Controller,MVVM拆胖Model,VIPER什么都拆),但即便拆分方式再怎么多样,那都只是招式。而拆分的规范,就是心法。这一节我就讲讲我在做View架构时,做拆分的心法。



  • 第一心法:保留最重要的任务,拆分其它不重要的任务


在iOS开发领域内,UIViewController承载了非常多的事情,比如View的初始化,业务逻辑,事件响应,数据加工等等,当然还有更多我现在也列举不出来,但是我们知道有一件事情Controller肯定逃不掉要做:协调V和M。也就是说,不管怎么拆,协调工作是拆不掉的。

那么剩下的事情我们就可以拆了,比如UITableView的DataSource。唐巧的博客有一篇文章提到他和另一个工程师关于是否要拆分DataSource争论了好久。拆分DataSource这个做法应该也算是通用做法,在不复杂的应用里面,它可能确实看上去只是一个数组而已,但在复杂的情况下,它背后可能涉及了文件内容读取,数据同步等等复杂逻辑,这篇文章的第一节就提倡了这个做法,我其实也蛮提倡的。

前面的文章里面也提了很多能拆的东西,我就不搬运了,大家可以进去看看。除了这篇文章提到的内容以外,任何比较大的,放在ViewController里面比较脏的,只要不是Controller的核心逻辑,都可以考虑拆出去,然后在架构的时候作为一个独立模块去定义,以及设计实现。



  • 第二心法:拆分后的模块要尽可能提高可复用性,尽量做到DRY


根据第一心法拆开来的东西,很有可能还是强业务相关的,这种情况有的时候无法避免。但我们拆也要拆得好看,拆出来的部分最好能够归成某一类对象,然后最好能够抽象出一个通用逻辑出来,使他能够复用。即使不能抽出通用逻辑,那也尽量抽象出一个protocol,来实现IOP。这里有篇关于IOP的文章,大家看了就明白优越性了。



  • 第三心法:要尽可能提高拆分模块后的抽象度


也就是说,拆分的粒度要尽可能大一点,封装得要透明一些。唐巧说一切隐藏都是对代码复杂性的增加,除非它带来了好处,这在一定程度上有点道理,没有好处的隐藏确实都不好(笑)。提高抽象度事实上就是增加封装的力度,将一个负责的业务抽象成只需要很少的输入就能完成,就是高度抽象。嗯,继承很多层,这种做法虽然也提高了抽象程度,但我不建议这么玩。我不确定唐巧在这里说的隐藏跟我说的封装是不是同一个概念,但我在这里想提倡的是尽可能提高抽象程度。

提高抽象程度的好处在于,对于业务方来说,他只需要收集很少的信息(最小充要条件),做很少的调度(Controller负责大模块调度,大模块里面再去做小模块的调度),就能够完成任务,这才是给Controller减负的正确姿势。

如果拆分出来的模块抽象程度不够,模块对外界要求的参数比较多,那么在Controller里面,关于收集参数的代码就会多了很多。如果一部分参数的收集逻辑能够由模块来完成,那也可以做到帮Controller减轻负担。否则就感觉拆得不太干净,因为Controller里面还是多了一些不必要的参数收集逻辑。

如果拆分出来的粒度太小,Controller在完成任务的时候调度代码要写很多,那也不太好。导致拆分粒度小的首要因素就是业务可能本身就比较复杂,拆分粒度小并不是不好,能大就大一点,如果小了,那也没问题。针对这种情况的处理,就需要采用strategy模式。


针对拆分粒度小的情况,我来举个实际例子,这个例子来源于我的一个朋友他在做聊天应用的消息发送模块。当消息是文字时,直接发送。当消息是图片时,需要先向服务器申请上传资源,获得资源ID之后再上传图片,上传图片完成之后拿到图片URL,后面带着URL再把信息发送出去。

这时候我们拆模块,可以拆成:数据发送(叫A模块),上传资源申请(叫B模块),内容上传(叫C模块)。那么要发送文字消息,Controller调度A就可以了。如果要发送图片消息,Controller调度B->C->A,假设将来还有上传别的类型消息的任务,他们又要依赖D/E/F模块,那这个事情就很蛋疼,因为逻辑复杂了,Controller要调度的东西要区分的情况就多了,Controller就膨胀了。

那么怎么处理呢?可以采用Strategy模式。我们再来分析一下,Controller要完成任务,它初始情况下所具有的条件是什么?它有这条消息的所有数据,也知道这个消息的类型。那么它最终需要的是什么呢?消息发送的结果:发送成功或失败。



                    send msg
    Controller ------------------> MessageSender
        ^                                |
        |                                |
        |                                |
        ----------------------------------
                 success / fail



上面就是我们要实现的最终结果,Controller只要把消息丢给MessageSender,然后让MessageSender去做事情,做完了告诉Controller就好了。那么MessageSender里面怎么去调度逻辑?MessageSender里面可以有一个StrategyList,里面存放了表达各种逻辑的Block或者Invocation(Target-Action)。那么我们先定义一个Enum,里面规定了每种任务所需要的调度逻辑。



typedef NS_ENUM (NSUInteger, MessageSendStrategy)
{
    MessageSendStrategyText = 0,
    MessageSendStrategyImage = 1,
    MessageSendStrategyVoice = 2,
    MessageSendStrategyVideo = 3
}



然后在MessageSender里面的StrategyList是这样:



@property (nonatomic, strong) NSArray *strategyList;

self.strategyList = @[TextSenderInvocation, ImageSenderInvocation, VoiceSenderInvocation, VideoSenderInvocation];

// 然后对外提供一个这样的接口,同时有一个delegate用来回调

- (void)sendMessage:(BaseMessage *)message withStrategy:(MessageSendStrategy)strategy;

@property (nonatomic, weak) id<MessageSenderDelegate> delegate;

@protocol MessageSenderDelegate<NSObject>

  @required
      - (void)messageSender:(MessageSender *)messageSender
      didSuccessSendMessage:(BaseMessage *)message
                   strategy:(MessageSendStrategy)strategy;

      - (void)messageSender:(MessageSender *)messageSender
         didFailSendMessage:(BaseMessage *)message
                   strategy:(MessageSendStrategy)strategy
                      error:(NSError *)error;
@end



Controller里面是这样使用的:



    [self.messageSender sendMessage:message withStrategy:MessageSendStrategyText];



MessageSender里面是这样的:



    [self.strategyList[strategy] invoke];



然后在某个Invocation里面,就是这样的:



    [A invoke];
    [B invoke];
    [C invoke];



这样就好啦,即便拆分粒度因为客观原因无法细化,那也能把复杂的判断逻辑和调度逻辑从Controller中抽出来,真正为Controller做到了减负。总之能够做到大粒度就尽量大粒度,实在做不到那也行,用Strategy把它hold住。这个例子是小粒度的情况,大粒度的情况太简单,我就不举了。




设计心法



针对View层的架构不光是看重如何合理地拆分MVC来给UIViewController减负,另外一点也要照顾到业务方的使用成本。最好的情况是业务方什么都不知道,然后他把代码放进去就能跑,同时还能获得框架提供的种种功能。



比如天安门广场上的观众看台,就是我觉得最好的设计,因为没人会注意到它。


  • 第一心法:尽可能减少继承层级,涉及苹果原生对象的尽量不要继承


继承是罪恶,尽量不要继承。就我目前了解到的情况看,除了安居客的Pad App没有在框架级针对UIViewController有继承的设计以外,其它公司或多或少都针对UIViewController有继承,包括安居客iPhone app(那时候我已经对此无能为力,可见View的架构在一开始就设计好有多么重要)。甚至有的还对UITableView有继承,这是一件多么令人发指,多么惨绝人寰,多么丧心病狂的事情啊。虽然不可避免的是有些情况我们不得不从苹果原生对象中继承,比如UITableViewCell。但我还是建议尽量不要通过继承的方案来给原生对象添加功能,前面提到的Aspect方案和Category方案都可以使用。用Aspect+load来实现重载函数,用Category来实现添加函数,当然,耍点手段用Category来添加property也是没问题的。这些方案已经覆盖了继承的全部功能,而且非常好维护,对于业务方也更加透明,何乐而不为呢。

不用继承可能在思路上不会那么直观,但是对于不使用继承带来的好处是足够顶得上使用继承的坏处的。顺便在此我要给Category正一下名:业界对于Category的态度比较暧昧,在多种场合(讲座、资料文档)都宣扬过尽可能不要使用Category。它们说的都有一定道理,但我认为Category是苹果提供的最好的使用集合代替继承的方案,但针对Category的设计对架构师的要求也很高,请合理使用。而且苹果也在很多场合使用Category,来把一个原本可能很大的对象,根据不同场景拆分成不同的Category,从而提高可维护性。

不使用继承的好处我在这里已经说了,放到iOS应用架构来看,还能再多额外两个好处:1. 在业务方做业务开发或者做Demo时,可以脱离App环境,或花更少的时间搭建环境。2. 对业务方来说功能更加透明,也符合业务方在开发时的第一直觉。



  • 第二心法:做好代码规范,规定好代码在文件中的布局,尤其是ViewController


这主要是为了提高可维护性。在一个文件非常大的对象中,尤其要限制好不同类型的代码在文件中的布局。比如在写ViewController时,我之前给团队制定的规范就是前面一段全部是getter setter,然后接下来一段是life cycle,viewDidLoad之类的方法都在这里。然后下面一段是各种要实现的Delegate,再下面一段就是event response,Button的或者GestureRecognizer的都在这里。然后后面是private method。一般情况下,如果做好拆分,ViewController的private method那一段是没有方法的。后来随着时间的推移,我发现开头放getter和setter太影响阅读了,所以后面改成全放在ViewController的最后。



  • 第三心法:能不放在Controller做的事情就尽量不要放在Controller里面去做


Controller会变得庞大的原因,一方面是因为Controller承载了业务逻辑,MVC的总结者(在正式提出MVC之前,或多或少都有人这么设计,所以说MVC的设计者不太准确)对Controller下的定义也是承载业务逻辑,所以Controller就是用来干这事儿的,天经地义。另一方面是因为在MVC中,关于Model和View的定义都非常明确,很少有人会把一个属于M或V的东西放到其他地方。然后除了Model和View以外,还会剩下很多模棱两可的东西,这些东西从概念上讲都算Controller,而且由于M和V定义得那么明确,所以直觉上看,这些东西放在M或V是不合适的,于是就往Controller里面塞咯。

正是由于上述两方面原因导致了Controller的膨胀。我们再细细思考一下,Model膨胀和View膨胀,要针对它们来做拆分其实都是相对容易的,Controller膨胀之后,拆分就显得艰难无比。所以如果能够在一开始就尽量把能不放在Controller做的事情放到别的地方去做,这样在第一时间就可以让你的那部分将来可能会被拆分的代码远离业务逻辑。所以我们要稍微转变一下思路:模棱两可的模块,就不要塞到Controller去了,塞到V或者塞到M或者其他什么地方都比塞进Controller好,便于将来拆分

所以关于前面我按下不表的关于胖Model和瘦Model的选择,我的态度是更倾向于胖Model。客观地说,业务膨胀之后,代码规模肯定少不了的,不管你技术再好,经验再丰富,代码量最多只能优化,该膨胀还是要膨胀的,而且优化之后代码往往也比较难看,使用各种奇技淫巧也是有代价的。所以,针对代码量优化的结果,往往要么就是牺牲可读性,要么就是牺牲可移植性(通用性),Every magic always needs a pay, you have to make a trade-off.

那么既然膨胀出来的代码,或者将来有可能膨胀的代码,不管放在MVC中的哪一个部分,最后都是要拆分的,既然迟早要拆分,那不如放Model里面,这样将来拆分胖Model也能比拆分胖Cotroller更加容易。在我还在安居客的时候,安居客Pad app承载最复杂业务的ViewController才不到600行,其他多数Controller都是在300-400行之间,这就为后面接手的人降低了非常多的上手难度和维护复杂度。拆分出来的东西都是可以直接迁移给iPhone app使用的。现在看天猫的ViewControler,动不动就几千行,看不了多久头就晕了,问了一下,大家都表示很习惯这样的代码长度,摊手。



  • 第四心法:架构师是为业务工程师服务的,而不是去使唤业务工程师的


架构师在公司里的职级和地位往往都是要高于业务工程师的,架构师的技术实力和经验往往也都是高于业务工程师的。所以你值得在公司里获得较高的地位,但是在公司里的地位高不代表在软件工程里面的角色地位也高。架构师是要为业务工程师服务的,是他们使唤你而不是你使唤他们。另外,制定规范一方面是起到约束业务工程师的代码,但更重要的一点是,这其实是利用你的能力帮助业务工程师避免他无法预见的危机,所以地位高有一定的好处,毕竟夏虫不可语冰,有的时候不见得能够解释得通,因此高地位随之而来的就是说服力会比较强。但在软件工程里,一定要保持谦卑,一定要多为业务工程师考虑。

一个不懂这个道理的架构师,设计出来的东西往往复杂难用,因为他只愿意做核心的东西,周边不愿意做的都期望交给业务工程师去做,甚至有的时候就只做了个Demo,然后就交给业务工程师了,业务工程师变成给他打工的了。但是一个懂得这个道理的架构师,设计出来的东西会非常好用,业务方只需要扔很少的参数然后拿结果就好了,这样的架构才叫好的架构。

举一个保存图片到本地的例子,一种做法是提供这样的接口:- (NSString *)saveImageWithData:(NSData *)imageData,另一种是- (NSString *)saveImage:(UIImage *)image。后者更好,原因自己想。

你的态度越谦卑,就越能设计出好的架构,这是我设计心法里的最后一条,也是最重要的一条。即使你现在技术实力不是业界大牛级别的,但只要保持这个心态去做架构,去做设计,就已经是合格的架构师了,要成为业界大牛也会非常快。




小总结



其实针对View层的架构设计,还是要做好三点:代码规范架构模式工具集

代码规范对于View层来说意义重大,毕竟View层非常重业务,如果代码布局混乱,后来者很难接手,也很难维护。

架构模式具体如何选择,完全取决于业务复杂度。如果业务相当相当复杂,那就可以使用VIPER,如果相对简单,那就直接MVC稍微改改就好了。每一种已经成为定式的架构模式不见得都适合各自公司对应的业务,所以需要各位架构师根据情况去做一些拆分或者改变。拆分一般都不会出现问题,改变的时候,只要别把MVC三个角色搞混就好了,M该做啥做啥,C该做啥做啥,V该做啥做啥,不要乱来。关于大部分的架构模式应该是什么样子,这篇文章里都已经说过了,不过我认为最重要的还是后面的心法,模式只是招术,熟悉了心法才能大巧不工

View层的工具集主要还是集中在如何对View进行布局,以及一些特定的View,比如带搜索提示的搜索框这种。这篇文章只提到了View布局的工具集,其它的工具集相对而言是更加取决于各自公司的业务的,各自实现或者使用CocoaPods里现成的都不是很难。

对于小规模或者中等规模iOS开发团队来说,做好以上三点就足够了。在大规模团队中,有一个额外问题要考虑,就是跨业务页面调用方案的设计。




跨业务页面调用方案的设计



跨业务页面调用是指,当一个App中存在A业务,B业务等多个业务时,B业务有可能会需要展示A业务的某个页面,A业务也有可能会调用其他业务的某个页面。在小规模的App中,我们直接import其他业务的某个ViewController然后或者push或者present,是不会产生特别大的问题的。但是如果App的规模非常大,涉及业务数量非常多,再这么直接import就会出现问题。



    --------------             --------------             --------------
    |            |  page call  |            |  page call  |            |
    | Buisness A | <---------> | Buisness B | <---------> | Buisness C |
    |            |             |            |             |            |
    --------------             --------------             --------------
                  \                   |                  /
                   \                  |                 /
                    \                 |                /
                     \                |               /
                      \               |              /
                      --------------------------------
                      |                              |
                      |              App             |
                      |                              |
                      --------------------------------



可以看出,跨业务的页面调用在多业务组成的App中会导致横向依赖。那么像这样的横向依赖,如果不去设法解决,会导致什么样的结果?


  1. 当一个需求需要多业务合作开发时,如果直接依赖,会导致某些依赖层上端的业务工程师在前期空转,依赖层下端的工程师任务繁重,而整个需求完成的速度会变慢,影响的是团队开发迭代速度。

  2. 当要开辟一个新业务时,如果已有各业务间直接依赖,新业务又依赖某个旧业务,就导致新业务的开发环境搭建困难,因为必须要把所有相关业务都塞入开发环境,新业务才能进行开发。影响的是新业务的响应速度。

  3. 当某一个被其他业务依赖的页面有所修改时,比如改名,涉及到的修改面就会特别大。影响的是造成任务量和维护成本都上升的结果。


当然,如果App规模特别小,这三点带来的影响也会特别小,但是在阿里这样大规模的团队中,像天猫/淘宝这样大规模的App,一旦遇上这里面哪怕其中一件事情,就特么很坑爹。



那么应该怎样处理这个问题?


让依赖关系下沉。

怎么让依赖关系下沉?引入Mediator模式。

所谓引入Mediator模式来让依赖关系下沉,实质上就是每次呼唤页面的时候,通过一个中间人来召唤另外一个页面,这样只要每个业务依赖这个中间人就可以了,中间人的角色就可以放在业务层的下面一层,这就是依赖关系下沉。



    --------------             --------------             --------------
    |            |             |            |             |            |
    | Buisness A |             | Buisness B |             | Buisness C |
    |            |             |            |             |            |
    --------------             --------------             --------------
                  \                   |                  /
                   \                  |                 /
                    \                 |                /  通过Mediater来召唤页面
                     \                |               /
                      \               |              /
                      --------------------------------
                      |                              |
                      |            Mediater          |
                      |                              |
                      --------------------------------
                                      |
                                      |
                                      |
                                      |
                                      |
                      --------------------------------
                      |                              |
                      |              App             |
                      |                              |
                      --------------------------------



当A业务需要调用B业务的某个页面的时候,将请求交给Mediater,然后由Mediater通过某种手段获取到B业务页面的实例,交还给A就行了。在具体实现这个机制的过程中,有以下几个问题需要解决:


  1. 设计一套通用的请求机制,请求机制需要跟业务剥离,使得不同业务的页面请求都能够被Mediater处理
  2. 设计Mediater根据请求如何获取其他业务的机制,Mediater需要知道如何处理请求,上哪儿去找到需要的页面


这个看起来就非常像我们web开发时候的URL机制,发送一个Get或Post请求,CGI调用脚本把请求分发给某个Controller下的某个Action,然后返回HTML字符串到浏览器去解析。苹果本身也实现了一套跨App调用机制,它也是基于URL机制来运转的,只不过它想要解决的问题是跨App的数据交流和页面调用,我们想要解决的问题是降低各业务的耦合度。

不过我们还不能直接使用苹果原生的这套机制,因为这套机制不能够返回对象实例。而我们希望能够拿到对象实例,这样不光可以做跨业务页面调用,也可以做跨业务的功能调用。另外,我们又希望我们的Mediater也能够跟苹果原生的跨App调用兼容,这样就又能帮业务方省掉一部分开发量。

就我目前所知道的情况,AutoCad旗下某款iOS应用(时间有点久我不记得是哪款应用了,如果你是AutoCad的iOS开发,可以在评论区补充一下。)就采用了这种页面调用方式。天猫里面目前也在使用这套机制,只是这一块由于历史原因存在新老版本混用的情况,因此暂时还没能够很好地发挥应有的作用。

嗯,想问我要Demo的同学,我可以很大方地告诉你,没有。不过我打算抽时间写一个出来,现在除了已经想好名字叫Summon以外,其它什么都没做,哈哈。




关于Getter和Setter?



我比较习惯一个对象的"私有"属性写在extension里面,然后这些属性的初始化全部放在getter里面做,在init和dealloc之外,是不会出现任何类似_property这样的写法的。就是这样:



@interface CustomObject()
@property (nonatomic, strong) UILabel *label;
@end

@implement

#pragma mark - life cycle

- (void)viewDidLoad
{
    [super viewDidLoad];

    [self.view addSubview:self.label];
}

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];

    self.label.frame = CGRectMake(1, 2, 3, 4);
}

#pragma mark - getters and setters

- (UILabel *)label
{
    if (_label == nil) {
        _label = [[UILabel alloc] init];
        _label.text = @"1234";
        _label.font = [UIFont systemFontOfSize:12];
        ... ...
    }
    return _label;
}
@end



唐巧说他喜欢的做法是用_property这种,然后关于_property的初始化通过[self setupProperty]这种做法去做。从刚才上面的代码来看,就是要在viewDidLoad里面多调用一个setup方法而已,然后我推荐的方法就是不用多调一个setup方法,直接走getter。

嗯,怎么说呢,其实两种做法都能完成需求。但是从另一个角度看,苹果之所以选择让[self getProperty]self.property可以互相通用,这种做法已经很明显地表达了苹果的倾向:希望每个property都是通过getter方法来获得

早在2003年,Allen Holub就发了篇文章《Why getter and setter methods are evil》,自此之后,业界就对此产生了各种争议,虽然是从Java开始说的,但是发展到后面各种语言也参与了进来。然后虽然现在关于这个问题讨论得少了,但是依旧属于没有定论的状态。setter的情况比较复杂,也不是我这一节的重点,我这边还是主要说getter。我们从objc的设计来看,苹果的设计者更加倾向于getter is not evil

认为getter is evil的原因有非常之多,或大或小,随着争论的进行,大家慢慢就聚焦到这样的一个原因:Getter和Setter提供了一个能让外部修改对象内部数据的方式,这是evil的,正常情况下,一个对象自己私有的变量应该是只有自己关心

然后我们回到iOS领域来,objc也同样面临了这样的问题,甚至更加严重:objc并没有像Java那么严格的私有概念。但在实际工作中,我们不太会去操作头文件里面没有的变量,这是从规范上就被禁止的。

认为getter is not evil的原因也可以聚焦到一个:高度的封装性。getter事实上是工厂方法,有了getter之后,业务逻辑可以更加专注于调用,而不必担心当前变量是否可用。我们可以想一下,假设一个ViewController有20个subview要加入view中,这20个subview的初始化代码是肯定逃不掉的,放在哪里比较好?放在哪里都比放在addsubview的地方好,我个人认为最好的地方还是放在getter里面,结合单例模式之后,代码会非常整齐,生产的地方和使用的地方得到了很好的区分。

所以放到iOS来说,我还是觉得使用getter会比较好,因为evil的地方在iOS这边基本都避免了,not evil的地方都能享受到,还是不错的。




总结



要做一个View层架构,主要就是从以下三方面入手:

  1. 制定良好的规范
  2. 选择好合适的模式(MVC、MVCS、MVVM、VIPER)
  3. 根据业务情况针对ViewController做好拆分,提供一些小工具方便开发

当然,你还会遇到其他的很多问题,这时候你可以参考这篇文章里提出的心法,在后面提到的跨业务页面调用方案的设计中,你也能够看到我的一些心法的影子。

对于iOS客户端来说,它并不像其他语言诸如Python、PHP他们有那么多的非官方通用框架。客观原因在于,苹果已经为我们做了非常多的事情,做了很多的努力。在苹果已经做了这么多事情的基础上,架构师要做针对View层的方案时,最好还是尽量遵守苹果已有的规范和设计思想,然后根据自己过去开发iOS时的经验,尽可能给业务方在开发业务时减负,提高业务代码的可维护性,就是View层架构方案的最大目标。




2015-04-28 09:28补:关于AOP



AOP(Aspect Oriented Programming),面向切片编程,这也是面向XX编程系列术语之一哈,但它跟我们熟知的面向对象编程没什么关系。



什么是切片?


程序要完成一件事情,一定会有一些步骤,1,2,3,4这样。这里分解出来的每一个步骤我们可以认为是一个切片。



什么是面向切片编程?


你针对每一个切片的间隙,塞一些代码进去,在程序正常进行1,2,3,4步的间隙可以跑到你塞进去的代码,那么你写这些代码就是面向切片编程。



为什么会出现面向切片编程?


你要想做到在每一个步骤中间做你自己的事情,不用AOP也一样可以达到目的,直接往步骤之间塞代码就好了。但是事实情况往往很复杂,直接把代码塞进去,主要问题就在于:塞进去的代码很有可能是跟原业务无关的代码,在同一份代码文件里面掺杂多种业务,这会带来业务间耦合。为了降低这种耦合度,我们引入了AOP。



如何实现AOP?


AOP一般都是需要有一个拦截器,然后在每一个切片运行之前和运行之后(或者任何你希望的地方),通过调用拦截器的方法来把这个jointpoint扔到外面,在外面获得这个jointpoint的时候,执行相应的代码。

在iOS开发领域,objective-C的runtime有提供了一系列的方法,能够让我们拦截到某个方法的调用,来实现拦截器的功能,这种手段我们称为Method Swizzling。Aspects通过这个手段实现了针对某个类和某个实例中方法的拦截。

另外,也可以使用protocol的方式来实现拦截器的功能,具体实现方案就是这样:


@protocol RTAPIManagerInterceptor <NSObject>

@optional
- (void)manager:(RTAPIBaseManager *)manager beforePerformSuccessWithResponse:(AIFURLResponse *)response;
- (void)manager:(RTAPIBaseManager *)manager afterPerformSuccessWithResponse:(AIFURLResponse *)response;

- (void)manager:(RTAPIBaseManager *)manager beforePerformFailWithResponse:(AIFURLResponse *)response;
- (void)manager:(RTAPIBaseManager *)manager afterPerformFailWithResponse:(AIFURLResponse *)response;

- (BOOL)manager:(RTAPIBaseManager *)manager shouldCallAPIWithParams:(NSDictionary *)params;
- (void)manager:(RTAPIBaseManager *)manager afterCallingAPIWithParams:(NSDictionary *)params;

@end

@interface RTAPIBaseManager : NSObject

@property (nonatomic, weak) id<RTAPIManagerInterceptor> interceptor;

@end


这么做对比Method Swizzling有个额外好处就是,你可以通过拦截器来给拦截器的实现者提供更多的信息,便于外部实现更加了解当前切片的情况。另外,你还可以更精细地对切片进行划分。Method Swizzling的切片粒度是函数粒度的,自己实现的拦截器的切片粒度可以比函数更小,更加精细。

缺点就是,你得自己在每一个插入点把调用拦截器方法的代码写上(笑),通过Aspects(本质上就是Mehtod Swizzling)来实现的AOP,就能轻松一些。




2015-4-29 14:25 补:关于在哪儿写Constraints?


文章发出来之后,很多人针对勘误1有很多看法,以至于我觉得很有必要在这里做一份补。期间过程很多很复杂,这篇文章也已经很长了,我就直接说结果了哈。


pic1


苹果在文档中指出,updateViewConstraints是用来做add constraints的地方。

但是在这里有一个回答者说updateViewConstraints并不适合做添加Constraints的事情。

综合我自己和评论区各位关心这个问题的兄弟们的各种测试和各种文档,我现在觉得还是在viewDidLoad里面开一个layoutPageSubviews的方法,然后在这个里面创建Constraints并添加,会比较好。就是像下面这样:


- (void)viewDidLoad
{
    [super viewDidLoad];

    [self.view addSubview:self.firstView];
    [self.view addSubview:self.secondView];
    [self.view addSubview:self.thirdView];

    [self layoutPageSubviews];
}

- (void)layoutPageSubviews
{
    [self.view addConstraints:xxxConstraints];
    [self.view addConstraints:yyyConstraints];
    [self.view addConstraints:zzzConstraints];
}


最后,要感谢评论区各位关心这个问题,并提出自己意见,甚至是自己亲自测试然后告诉我结果的各位兄弟:@fly2never,@Wythe,@wtlucky,@lcddhr,@李新星,@Meigan Fang,@匿名,@Xiao Moch。

这个做法是目前我自己觉得可能比较合适的做法,当然也欢迎其他同学继续拿出自己的看法,我们来讨论。




勘误


我的前同事@ddaajing看了这篇文章之后,给我提出了以下两个勘误,和很多行文上的问题。在这里我对他表示非常感谢:

勘误1:其实在viewWillAppear这里改变UI元素不是很可靠,Autolayout发生在viewWillAppear之后,严格来说这里通常不做视图位置的修改,而用来更新Form数据。改变位置可以放在viewWilllayoutSubview或者didLayoutSubview里,而且在viewDidLayoutSubview确定UI位置关系之后设置autoLayout比较稳妥。另外,viewWillAppear在每次页面即将显示都会调用,viewWillLayoutSubviews虽然在lifeCycle里调用顺序在viewWillAppear之后,但是只有在页面元素需要调整时才会调用,避免了Constraints的重复添加。


勘误2:MVVM要有ViewModel,以及ReactiveCocoa带来的信号通知效果,在ReactiveCocoa里就是RAC等相关宏来实现。另外,使用ReactiveCocoa能够比较优雅地实现MVVM模式,就是因为有RAC等相关宏的存在。就像它的名字一样Reactive-响应式,这也是区分MVVM的VM和MVC的C和MVP的P的一个重要方面。




有任何问题建议直接在评论区提问,这样后来的人如果有相同的问题,就能直接找到答案了。提问之前也可以先看看评论区有没有人问过类似问题了。

所有评论和问题我都会在第一时间回复,QQ上我是不回答问题的哈。




iOS应用架构谈 开篇

作者 Casa Taloyum
2015年4月2日 00:00




iOS应用架构谈 开篇
iOS应用架构谈 view层的组织和调用方案
iOS应用架构谈 网络层设计方案
iOS应用架构谈 本地持久化方案及动态部署
iOS应用架构谈 组件化方案




缘由


之前安居客iOS app的第二版架构大部分内容是我做的,期间有总结了一些经验。在将近一年之后,前同事zzz在微信朋友圈上发了一个问题:假如问你一个iOS or Android app的架构,你会从哪些方面来说呢?

当时看到这个问题正好在乘公车回家的路上,闲来无聊就答了一把。在zzz在微信朋友圈上追问了几个问题之后,我觉得有必要开个博客专门来讲讲一些个人见解。

其实对于iOS客户端应用的架构来说,复杂度不亚于服务端,但侧重点和入手点却跟服务端不太一样。比如客户端应用就不需要考虑类似C10K的问题,正常的app就根本不需要考虑。

这系列文章我会主要专注在iOS应用架构方面,很多方案也是基于iOS技术栈的特点而建立的。因为我个人不是很喜欢写Java,所以Android这边的我就不太了解了。如果你是Android开发者,你可以侧重看我提出的一些架构思想,毕竟不管做什么,思路是相通的,实现手段不同罢了。




当我们讨论客户端应用架构的时候,我们在讨论什么?


其实市面上大部分应用不外乎就是颠过来倒过去地做以下这些事情:

    ---------------     ---------------     ---------------     ---------------
    |             |     |             |     |             |     |             |
    | 调用网络API  | --> |   展现列表    | --> |  选择列表    | --> |   展现单页   |
    |             |     |             |     |             |     |             |
    ---------------     ---------------     ---------------     ---------------
                               ^                                        |
                               |                                        |
                               |                                        |
                               ------------------------------------------

简单来说就是调API,展示页面,然后跳转到别的地方再调API,再展示页面。


那这特么有毛好架构的?


非也,非也。 ---- 包不同 《天龙八部》



App确实就是主要做这些事情,但是支撑这些事情的基础,就是做架构要考虑的事情。

  • 调用网络API
  • 页面展示
  • 数据的本地持久化
  • 动态部署方案



上面这四大点,稍微细说一下就是:

  • 如何让业务开发工程师方便安全地调用网络API?然后尽可能保证用户在各种网络环境下都能有良好的体验?
  • 页面如何组织,才能尽可能降低业务方代码的耦合度?尽可能降低业务方开发界面的复杂度,提高他们的效率?
  • 当数据有在本地存取的需求的时候,如何能够保证数据在本地的合理安排?如何尽可能地减小性能消耗?
  • iOS应用有审核周期,如何能够通过不发版本的方式展示新的内容给用户?如何修复紧急bug?



上面几点是针对App说的,下面还有一些是针对团队说的:

  • 收集用户数据,给产品和运营提供参考
  • 合理地组织各业务方开发的业务模块,以及相关基础模块
  • 每日app的自动打包,提供给QA工程师的测试工具

一时半会儿我还是只能想到上面这三点,事实上应该还会有很多,想不起来了。




所以当我们讨论客户端应用架构的时候,我们讨论的差不多就是这些问题。




这系列文章要回答那些问题?


这系列文章主要是回答以下这些问题:

  1. 网络层设计方案?设计网络层时要考虑哪些问题?对网络层做优化的时候,可以从哪些地方入手?
  2. 页面的展示、调用和组织都有哪些设计方案?我们做这些方案的时候都要考虑哪些问题?
  3. 本地持久化层的设计方案都有哪些?优劣势都是什么?不同方案间要注意的问题分别都是什么?
  4. 要实现动态部署,都有哪些方案?不同方案之间的优劣点,他们的侧重点?




本文要回答那些问题?


上面细分出来的四个问题,我会分别在四篇文章里面写。那么这篇文章就是来讲一些通识啥的,也是开个坑给大家讨论通识问题的。




架构设计的方法


所有事情最难的时候都是开始做的时候,当你开始着手设计并实现某一层的架构乃至整个app的架构的时候,很有可能会出现暂时的无从下手的情况。以下方法论是我这些年总结出来的经验,每个架构师也一定都有一套自己的方法论,但一样的是,不管你采用什么方法,全局观、高度的代码审美能力、灵活使用各种设计模式一定都是贯穿其中的。欢迎各位在评论区讨论。



第一步:搞清楚要解决哪些问题,并找到解决这些问题的充要条件


你必须得清楚你要做什么,业务方希望要什么。而不是为了架构而架构,也不是为了体验新技术而改架构方案。以前是MVC,最近流行MVVM,如果过去的MVC是个好架构,没什么特别大的缺陷,就不要推倒然后搞成MVVM。

关于充要条件我也要说明一下,有的时候系统提供的函数是需要额外参数的,比如read函数。还有翻页的时候,当前页码也是充要条件。但对于业务方来说,这些充要条件还能够再缩减。

比如read,需要给出file descriptor,需要给出buf,需要给出size。但是对于业务方来说,充要条件就只要file descriptor就够了。再比如翻页,其实业务方并不需要记录当前页号,你给他暴露一个loadNextPage这样的方法就够了。

搞清楚对于业务方而言的真正充要条件很重要!这决定了你的架构是否足够易用。另外,传的参数越少,耦合度相对而言就越小,你替换模块或者升级模块所花的的代价就越小。



第二步:问题分类,分模块


这个不用多说了吧。



第三步:搞清楚各问题之间的依赖关系,建立好模块交流规范并设计模块


关键在于建立一套统一的交流规范。这一步很能够体现架构师在软件方面的价值观,虽然存在一定程度上的好坏优劣(比如胖Model和瘦Model),但既然都是架构师了,基本上是不会设计出明显很烂的方案的,除非这架构师还不够格。所以这里是架构师价值观输出的一个窗口,从这一点我们是能够看出架构师的素质的。

另外要注意的是,一定是建立一套统一的交流规范,不是两套,不是多套。你要坚持你的价值观,不要摇摆不定。要是搞出各种五花八门的规范出来,一方面有不切实际的炫技嫌疑,另一方面也会带来后续维护的灾难。



第四步:推演预测一下未来可能的走向,必要时添加新的模块,记录更多的基础数据以备未来之需


很多称职的架构师都会在这时候考虑架构未来的走向,以及考虑做完这一轮架构之后,接下来要做的事情。一个好的架构虽然是功在当代利在千秋的工程,但绝对不是一个一劳永逸的工程。软件是有生命的,你做出来的架构决定了这个软件它这一生是坎坷还是幸福。



第五步:先解决依赖关系中最基础的问题,实现基础模块,然后再用基础模块堆叠出整个架构


这一步也是验证你之前的设计是否合理的一步,随着这一步的推进,你很有可能会遇到需要对架构进行调整的情况。这个阶段一定要吹毛求疵高度负责地去开发,不要得过且过,发现架构有问题就及时调整。否则以后调整的成本就非常之大了。



第六步:打点,跑单元测试,跑性能测试,根据数据去优化对应的地方


你得用这些数据去向你的boss邀功,你也得用这些数据去不断调整你的架构。




总而言之就是要遵循这些原则:自顶向下设计(1,2,3,4步),自底向上实现(5),先测量,后优化(6)。




什么样的架构师是好架构师?


  1. 每天都在学习,新技术新思想上手速度快,理解速度快

做不到这点,你就是码农



  1. 业务出身,或者至少非常熟悉公司所处行业或者本公司的业务

做不到这点,你就是运维



  1. 熟悉软件工程的各种规范,踩过无数坑。不会为了完成需求不择手段,不推崇quick & dirty

做不到这点,你比较适合去竞争对手那儿当工程师



  1. 及时承认错误,不要觉得承认错误会有损你架构师的身份

做不到这点,公关行业比较适合你



  1. 不为了炫技而炫技

做不到这点,你就是高中编程爱好者



  1. 精益求精

做不到这点,(我想了好久,但我还是不知道你适合去干什么。)




什么样的架构叫好架构?


  1. 代码整齐,分类明确,没有common,没有core
  2. 不用文档,或很少文档,就能让业务方上手
  3. 思路和方法要统一,尽量不要多元
  4. 没有横向依赖,万不得已不出现跨层访问
  5. 对业务方该限制的地方有限制,该灵活的地方要给业务方创造灵活实现的条件
  6. 易测试,易拓展
  7. 保持一定量的超前性
  8. 接口少,接口参数少
  9. 高性能

以上是我判断一个架构是不是好架构的标准,这是根据重要性来排列的。客户端架构跟服务端架构要考虑的问题和侧重点是有一些区别的。下面我会针对每一点详细讲解一下:



代码整齐,分类明确,没有common,没有core

代码整齐是每一个工程师的基本素质,先不说你搞定这个问题的方案有多好,解决速度有多快,如果代码不整齐,一切都白搭。因为你的代码是要给别人看的,你自己也要看。如果哪一天架构有修改,正好改到这个地方,你很容易自己都看不懂。另外,破窗理论提醒我们,如果代码不整齐分类不明确,整个架构会随着一次一次的拓展而越来越混乱。

分类明确的字面意思大家一定都了解,但还有一个另外的意思,那就是:不要让一个类或者一个模块做两种不同的事情。如果有类或某模块做了两种不同的事情,一方面不适合未来拓展,另一方面也会造成分类困难。

不要搞Common,Core这些东西。每家公司的架构代码库里面,最恶心的一定是这两个名字命名的文件夹,我这么说一定不会错。不要开Common,Core这样的文件夹,开了之后后来者一定会把这个地方搞得一团糟,最终变成Common也不Common,Core也不Core。要记住,架构是不断成长的,是会不断变化的。不是每次成长每次变化,都是由你去实现的。如果真有什么东西特别小,那就索性为了他单独开辟一个模块就好了,小就小点,关键是要有序。



不用文档,或很少文档,就能让业务方上手

谁特么会去看文档啊,业务方他们已经被产品经理逼得很忙了。所以你要尽可能让你的API名字可读性强,对于iOS来说,objc这门语言的特性把这个做到了极致,函数名长就长一点,不要紧。


好的函数名
    - (NSDictionary *)exifDataOfImage:(UIImage *)image atIndexPath:(NSIndexPath *)indexPath;

坏的函数名
    - (id)exifData:(UIImage *)image position:(id)indexPath callback:(id<ErrorDelegate>)delegate;

为什么坏
    1. 不要直接返回id或者传入id实在不行用id<protocol>也比id好如果连这个都做不到你要好好考虑你的架构是不是有问题
    2. 要告知业务方要传的东西是什么比如要传Image那就写上ofImage如果要传位置那就要写上IndexPath而不是用position这么笼统的东西
    3. 没有任何理由要把delegate作为参数传进去一定不会有任何情况不得不这么做的而且delegate这个参数根本不是这个函数要解决的问题的充要条件如果你发现你不得不这么做那一定是架构有问题



思路和方法要统一,尽量不要多元

解决一个问题会有很多种方案,但是一旦确定了一种方案,就不要在另一个地方采用别的方案了。也就是做架构的时候,你得时刻记住当初你决定要处理这样类型的问题的方案是什么,以及你的初衷是什么,不要摇摆不定。

另外,你当初设立这个模块一定是有想法有原因的,要记录下你的解决思路,不要到时候换个地方你又灵光一现啥的,引入了其他方案,从而导致异构。

要是一个框架里面解决同一种类似的问题有各种五花八门的方法或者类,我觉得做这个架构的架构师一定是自己都没想清楚就开始搞了。



没有横向依赖,万不得已不出现跨层访问

没有横向依赖是很重要的,这决定了你将来要对这个架构做修补所需要的成本有多大。要做到没有横向依赖,这是很考验架构师的模块分类能力和是否熟悉业务的。

跨层访问是指数据流向了跟自己没有对接关系的模块。有的时候跨层访问是不可避免的,比如网络底层里面信号从2G变成了3G变成了4G,这是有可能需要跨层通知到View的。但这种情况不多,一旦出现就要想尽一切办法在本层搞定或者交给上层或者下层搞定,尽量不要出现跨层的情况。跨层访问同样也会增加耦合度,当某一层需要整体替换的时候,牵涉面就会很大。



对业务方该限制的地方有限制,该灵活的地方要给业务方创造灵活实现的条件

把这点做好,很依赖于架构师的经验。架构师必须要有能力区分哪些情况需要限制灵活性,哪些情况需要创造灵活性。比如对于Core Data技术栈来说,ManagedObject理论上是可以出现在任何地方的,那就意味着任何地方都可以修改ManagedObject,这就导致ManagedObjectContext在同步修改的时候把各种不同来源的修改同步进去。这时候就需要限制灵活性,只对外公开一个修改接口,不暴露任何ManagedObject在外面。

如果是设计一个ABTest相关的API的时候,我们又希望增加它的灵活性。使得业务方不光可以通过Target-Action的模式实现ABtest,也要可以通过Block的方式实现ABTest,要尽可能满足灵活性,减少业务方的使用成本。



易测试易拓展

老生常谈,要实现易测试易拓展,那就要提高模块化程度,尽可能减少依赖关系,便于mock。另外,如果是高度模块化的架构,拓展起来将会是一件非常容易的事情。



保持一定量的超前性

这一点能看出架构师是否关注行业动态,是否能准确把握技术走向。保持适度的技术上的超前性,能够使得你的架构更新变得相对轻松。

另外,这里的超前性也不光是技术上的,还有产品上的。谁说架构师就不需要跟产品经理打交道了,没事多跟产品经理聊聊天,听听他对产品未来走向的畅想,你就可以在合理的地方为他的畅想留一条路子。同时,在创业公司的环境下,很多产品需求其实只是为了赶产品进度而产生的妥协方案,最后还是会转到正轨的。这时候业务方可以不实现转到正规的方案,但是架构这边,是一定要为这种可预知的改变做准备的。



接口少,接口参数少

越少的接口越少的参数,就能越降低业务方的使用成本。当然,充要条件还是要满足的,如何在满足充要条件的情况下尽可能地减少接口和参数数量,这就能看出架构师的功力有多深厚了。



高性能

为什么高性能排在最后一位?

高性能非常重要,但是在客户端架构中,它不是第一考虑因素。原因有下:


  • 客户端业务变化非常之快,做架构时首要考虑因素应当是便于业务方快速满足产品需求,因此需要尽可能提供简单易用效果好的接口给业务方,而不是提供高性能的接口给业务方。
  • 苹果平台的性能非常之棒,正常情况下很少会出现由于性能不够导致的用户体验问题。
  • 苹果平台的优化手段相对有限,甚至于有些时候即便动用了无所不用其极的手段乃至不择手段牺牲了稳定性,性能提高很有可能也只不过是100ms到90ms的差距。10%的性能提升对于服务端来说很不错了,因为服务端动不动就是几十万上百万的访问量,几十万上百万个10ms是很可观的。但是对于客户端的用户来说,他无法感知这10ms的差别,如果从10s优化成9s用户还是有一定感知的,但是100ms变90ms,我觉得吧,还是别折腾了。


但是!不重要不代表用不着去做,关于性能优化的东西,我会对应放到各系列文章里面去。比如网络层优化,那就会在网络层方案的那篇文章里面去写,对应每层架构都有每层架构的不同优化方案,我都会在各自文章里面一一细说。




2015-4-2 11:28 补: 关于架构分层?


昨晚上志豪看了这篇文章之后说,看到你这个题目本来我是期望看到关于架构分层相关的东西的,但是你没写。

嗯,确实没写,当时没写的原因是感觉这个没什么好写的。前面谈论到架构的方法的时候,关于问题分类分模块这一步时,架构分层也属于这一部分,给我一笔带过了。

既然志豪提出来了这个问题,我想可能大家关于这个也会有一些想法和问题,那么我就在这儿讲讲吧。



其实分层这种东西,真没啥技术含量,全凭架构师的经验和素质。


我们常见的分层架构,有三层架构的:展现层、业务层、数据层。也有四层架构的:展现层、业务层、网络层、本地数据层。这里说三层四层,跟TCP/IP所谓的五层或者七层不是同一种概念。再具体说就是:你这个架构在逻辑上是几层那就几层,具体每一层叫什么,做什么,没有特定的规范。这主要是针对模块分类而言的。

也有说MVC架构,MVVM架构的,这种层次划分,主要是针对数据流动的方向而言的。

在实际情况中,针对数据流动方向做的设计和针对模块分类做的设计是会放在一起的,也就是说,一个MVC架构可以是四层:展现层、业务层、网络层、本地数据层。

那么,为什么我要说这个?

大概在五六年前,业界很流行三层架构这个术语。然后各种文档资料漫天的三层架构,并且喜欢把它与MVC放在一起说,MVC三层架构/三层架构MVC,以至于很多人就会认为三层架构就是MVCMVC就是三层架构。其实不是的。三层架构里面其实没有Controller的概念,而且三层架构描述的侧重点是模块之间的逻辑关系。MVCController的概念,它描述的侧重点在于数据流动方向。



好,为什么流行起来的是三层架构,而不是四层架构五层架构


因为所有的模块角色只会有三种:数据管理者数据加工者数据展示者,意思也就是,笼统说来,软件只会有三层,每一层扮演一个角色。其他的第四层第五层,一般都是这三层里面的其中之一分出来的,最后都能归纳进这三层的某一层中去,所以用三层架构来描述就比较普遍。



那么我们怎么做分层?


应该如何做分层,不是在做架构的时候一开始就考虑的问题。虽然我们要按照自顶向下的设计方式来设计架构,但是一般情况下不适合直接从三层开始。一般都是先确定所有要解决的问题,先确定都有哪些模块,然后再基于这些模块再往下细化设计。然后再把这些列出来的问题和模块做好分类。分类之后不出意外大多数都是三层。如果发现某一层特别庞大,那就可以再拆开来变成四层,变成五层。


举个例子:你要设计一个即时通讯的服务端架构,怎么分层?

记住,不要一上来就把三层架构的规范套上去,这样做是做不出好架构的。

你要先确定都需要解决哪些问题。这里只是举例子,我随意列出一点意思意思就好了:

  1. 要解决用户登录、退出的问题
  2. 解决不同用户间数据交流的问题
  3. 解决用户数据存储的问题
  4. 如果是多台服务器的集群,就要解决用户连接的寻址问题


解决第一个问题需要一个链接管理模块,链接管理模块一般是通过链接池来实现。 解决第二个问题需要有一个数据交换模块,从A接收来的数据要给到B,这个事情由这个模块来做。 解决第三个问题需要有个数据库,如果是服务于大量用户,那么就需要一个缓冲区,只有当需要存储的数据达到一定量时才执行写操作。 解决第四个问题可以有几种解决方案,一个是集群中有那么几台服务器作为寻路服务器,所有寻路的服务交给那几台去做,那么你需要开发一个寻路服务的Daemon。或者用广播方式寻路,但如果寻路频次非常高,会造成集群内部网络负载特别大。这是你要权衡的地方,目前流行的思路是去中心化,那么要解决网络负载的问题,你就可以考虑配置一个缓存。

于是我们有了这些模块:

链接管理、数据交换、数据库及其配套模块、寻路模块

做到这里还远远没有结束,你要继续针对这四个模块继续往下细分,直到足够小为止。但是这里只是举例子,所以就不往下深究了。

另外,我要提醒你的是,直到这时,还是跟几层架构毫无关系的。当你把所有模块都找出来之后,就要开始整理你的这些模块,很有可能架构图就是这样:

    链接管理  收发数据                     收发数据
        数据交换                       /        \ 
                            \   链接管理        数据交换
    寻路服务          ========\                   /  \
                    ========/            数据库服务   寻路服务
        数据库服务           / 

然后这些模块分完之后你看一下图,嗯,1、2、3,一共三层,所以那就是三层架构啦。在这里最消耗脑力最考验架构师功力的地方就在于:找到所有需要的模块, 把模块放在该放的地方

这个例子侧重点在于如何分层,性能优化、数据交互规范和包协议、数据采集等其他一系列必要的东西都没有放进去,但看到这里,相信你应该了解架构师是怎么对待分层问题的了吧?


对的,答案就是没有分层。所谓的分层都是出架构图之后的事情了。所以你看别的架构师在演讲的时候,上来第一句话差不多都是:"这个架构分为以下几层..."。但考虑分层的问题的时机绝对不是一开始就考虑的。另外,模块一定要把它设计得独立性强,这其实是门艺术活。


另外,这虽然是服务端架构,但是思路跟客户端架构是一样的,侧重点不同罢了。之所以不拿客户端架构举例子,是因为这方面的客户端架构苹果已经帮你做好了绝大部分事情,没剩下什么值得说的了。




2015-4-5 12:15 补:关于Common文件夹?


评论区MatrixHero提到一点:


关于common文件夹的问题,仅仅是文件夹而已,别无他意。如果后期维护出了代码混乱可能是因为,和服务器沟通协议不统一,或代码review不及时。应该有专人维护公共类。


这是针对我前面提出的不要Common,不要Core而言的,为什么我建议大家不要开Common文件夹?我打算分几种情况给大家解释一下。

一般情况下,我们都会有一些属于这个项目的公共类,比如取定位坐标,比如图像处理。这些模块可能非常小,就h和m两个文件。单独拎出来成为一个模块感觉不够格,但是又不属于其他任何一个模块。于是大家很有可能就会把它们放入Common里面,我目前见到的大多数工程和大多数文档里面的代码都喜欢这么做。在当时来看,这么做看不出什么问题,但关键在于:软件是有生命,会成长的。当时分出来的小模块,很有可能会随着业务的成长,逐渐发展成大模块,发展成大模块后,可以再把它从Common移出来单独成立一个模块。这个在理论上是没有任何问题的,然而在实际操作过程中,工程师在拓张这个小模块的时候,不太容易会去考虑横向依赖的问题,因为当时这些模块都在Common里面,直接进行互相依赖是非常符合直觉的,而且也不算是不遵守规范。然而要注意的是,这才是Commom代码混乱的罪魁祸首,Common文件夹纵容了不精心管理依赖的做法。当Common里面的模块依赖关系变得复杂,再想要移出来单独成立一个模块,就不是当初设置Common时想的等规模大了再移除也不迟那么简单了。


另外,Common有的时候也不仅仅是一个文件夹。


在使用Cocoapods来管理项目库的时候,Common往往就是一个pod。这个pod里面会有A/B/C/D/E这些函数集或小模块。如果要新开一个app或者Demo,势必会使用到Common这个pod,这么做,往往会把不需要包含的代码也包含进去,我对项目有高度洁癖,这种情况会让我觉得非常不舒服。




举个例子:早年安居客的app还不是集齐所有新房二手房租房业务的。当你刚开始写新房这个app的时候,创建了一个Common这个pod,这里面包含了一些对于新房来说比较Common的代码,也包含了对于这个app来说比较Common的代码。过了半年或者一年,你要开始二手房这个app,我觉得大多数人都会选择让二手房也包含这个Common,于是这个Common很有可能自己走上另一条发展的道路。等到了租房这个业务要开app的时候,Common已经非常之庞大,相信这时候的你也不会去想整理Common的事情了,先把租房搞定,于是Common最终就变成了一坨屎。

就对于上面的例子来说,还有一个要考虑的是,分出来的三个业务很有可能会有三个Common,假设三个Common里面都有公共的功能,交给了三个团队去打理,如果遇到某个子模块需要升级,那么三个Common里面的这个子模块都要去同步升级,这是个很不效率的事情。另外,很有可能三个Common到最后发展成彼此不兼容,但是代码相似度非常之高,这个在架构上,是属于分类条理不清

就在去年年中的时候,安居客决定将三个业务归并到同一个App。好了,如果你是架构师,面对这三个Common,你打算怎么办?要想最快出成果,那就只好忍受代码冗余,赶紧先把架子搭起来再说,否则你面对的就是剪不断理还乱的Common。此时Common就已经很无奈地变成一坨屎了。这样的Common,你自己说不定也搞不清楚它里面到底都有些什么了,交给任何一个人去打理,他都不敢做彻底的整理的。




还有就是,Common本身就是一个粒度非常大的模块。在阿里这样大规模的团队中,即便新开一个业务,都需要在整个app的环境下开发,为什么?因为模块拆分粒度不够,要想开一个新业务,必须把其他业务的代码以及依赖全部拉下来,然后再开新入口,你的新业务才能进行正常的代码编写和调试。然而你的新业务其实只依赖首页入口、网络库等这几个小模块,不需要依赖其他那么多的跟你没关系的业务。现在每次打开天猫的项目,我都要等个两三分钟,这非常之蛋疼。

但是大家真的不知道这个原因吗?知道了这个原因,为什么没人去把这些粒度不够细的模块整理好?在我看来,这件事没人敢做。

  1. 原来大家用的好好的,手段烂就烂一点,你改了你能保证不出错?
  2. 这么复杂的东西,短期之内你肯定搞不好,任务量和工时都不好估,你leader会觉得你在骗工时玩自己的事情。
  3. 就算你搞定了,QA这边肯定再需要做一次全面的回归测试,任务量极大,难以说服他们配合你的工作。

花这么大的成本只是为了减少开启项目时候等待IDE打开时的那几分钟时间?我想如果我是你leader,我也应该不会批准你做这样的事情的。所以,与其到了后面吃这个苦头,不如一开始做架构的时候就不要设置Common,到后面就能省力很多。架构师的工作为什么是功在当代利在千秋,架构师的素质为什么对团队这么重要?我觉得这里就是一个最好的体现。




简而言之,不建议开Common的原因如下:

  1. Common不仅仅是一个文件夹,它也会是一个Pod。不管是什么,在Common里面很容易形成错综复杂的小模块依赖,在模块成长过程中,会纵容工程师不注意依赖的管理,乃至于将来如果要将模块拆分出去,会非常的困难。
  2. Common本身与细粒度模块设计的思想背道而驰,属于一种不合适的偷懒手段,在将来业务拓张会成为阻碍。
  3. 一旦设置了Common,就等于给地狱之门打开了一个小缝,每次业务迭代都会有一些不太好分类的东西放入Common,这就给维护Common的人带来了非常大的工作量,而且这些工作量全都是体力活,非常容易出错。


那么,不设Common会带来哪些好处?


  1. 强迫工程师在业务拓张的时候将依赖管理的事情考虑进去,让模块在一开始发展的时候就有自己的土壤,成长空间和灵活度非常大。
  2. 减少各业务模块或者Demo的体积,不需要的模块不会由于Common的存在而包含在内。
  3. 可维护性大大提高,模块升级之后要做的同步工作非常轻松,解放了那个苦逼的Common维护者,更多的时间可以用在更实质的开发工作上。
  4. 符合细粒度模块划分的架构思想。

Common的好处只有一个,就是前期特别省事儿。然而它的坏处比好处要多太多。不设置Common,再小的模块再小的代码也单独拎出来,最多就是Podfile里面要多写几行,多写几行最多只花费几分钟。但若要消除Common所带来的罪孽,不是这几分钟就能搞定的事情。既然不用Common的好处这么多,那何乐而不为呢?

假设将来你的项目中有一个类是用来做Location的,哪怕只有两个文件,也给他开一个模块就叫Location。如果你的项目中有一个类是用来做ImageProcess的,那也开一个模块就叫ImageProcess。不要都放到Common里面去,将来你再开新的项目或者新的业务,用Location就写依赖Location,用ImageProcess就写依赖ImageProcess,不要再依赖Common了,这样你的项目也好管理,管理Common的那个人日子过得也轻松(这个人其实都可以不需要了,把他的工资加到你头上不是更好?:D),将来要升级,顾虑也少。




结束


一下子挖了个大坑,在开篇里扯了一些淡。


嗯,干货会在后续的系列文章里面扑面而来的!




有任何问题建议直接在评论区提问,这样后来的人如果有相同的问题,就能直接找到答案了。提问之前也可以先看看评论区有没有人问过类似问题了。

所有评论和问题我都会在第一时间回复,QQ上我是不回答问题的哈。




作者 Casa Taloyum
2015年3月5日 00:00





啰嗦一下


模块化开发不光要求代码级的模块化,比如区分各种功能,然后把功能的实现分散在各个对象或文件。大部分情况也要求部署级的模块化,使之能够通过使用库的方式模块化加载,模块化部署。在这一波"模块化"大潮中,各种不同类型的库被广泛使用着。如果你希望你的程序能够支持插件功能,其本质也依旧是通过库来实现。

其实库的本质是一堆函数实现的集合,他们被编译在某一个文件里,然后根据使用方式的不同,分为静态库动态库两类。在动态库里,又分为动态链接库动态加载库




静态库 vs 动态库


一般来说,build一个项目的过程是先compile然后再link,然后才有一个可执行文件。link的时候要做的一件事情就是把各种函数符号转换成函数调用地址,然后最终生成的可执行文件就能够直接调用到函数了。

静态库是在build的时候就把库里面的代码链接进可执行文件

动态库的做法跟静态库的做法不一样,不会在build的时候就把代码link进可执行文件。然而对于两种不同的动态库而言,它们又有所区别:


  • 对于动态链接库而言,build可执行文件的时候需要指定它依赖哪些库,当可执行文件运行时,如果操作系统没有加载过这些库,那就会把这些库随着可执行文件的加载而加载进内存中,供可执行程序运行。如果多个可执行文件依赖同一个动态链接库,那么内存中只会有一份动态链接库的代码,然后把它共享给所有相关可执行文件的进程使用,所以它也叫共享库(shared library)。比如pthread就是一个这样的库。

  • 对于动态加载库而言,build可执行文件的时候就不需要指定它依赖哪些库,当可执行文件运行时,如果需要加载某个库,就用dlopendlsymdlclose等函数来动态地把库加载到内存,并调用库里面的函数。各大软件的插件模块基本上就都是这样的库。事实上,静态库和动态链接库也可以被动态加载,只是由于使用方式的不同,才多了一个动态加载库这样的类别。



他们的区别说白了就是加载库的时机,动态链接库在可执行文件得到运行的时候就加载,动态加载库在可执行文件运行期间的任何一个阶段都可以动态加载。大部分情况业界推荐使用动态库,至于动态链接还是动态加载,那就可以根据具体需要来。推荐更多使用动态库的原因如下:


  • 在静态库方案下,库的版本更新之后,需要重新编译程序,才能使得更新后的代码起作用。而动态库只需要编译对应的库代码,然后重启程序即可。
  • 使用动态库能够减少可执行文件的体积,因为公共功能被独立了出来,使得每个相关可执行文件不必把这部分代码也编入,从而减小了体积。


但是使用动态库也会带来一个缺点,那就是debug的时候特么超级麻烦。我个人是喜欢在debug的时候不使用库方案来生成可执行文件去debug,直接用.o文件链接生成可执行文件。部署的时候才会使用动态链接方案生成可执行文件。这两者的区别就只是几条编译指令的区别而已,在makefile里面写好就好了。




孔乙己


我在翻阅各种资料的时候,发现关于各种库的术语有特别多,我觉得有必要在这里做个辨析,如果大家都已经熟悉了那就可以跳过这一节。

关于常见的DLL,有的时候大家会把它理解成Dynamic Linked Library(动态链接库),也有的时候会把它理解成Dynamic Load Library(动态加载库),这个就要自己看上下文去区分了。

关于Shared Library,那就是指Dynamic Load Libraryso(Shared Object)也是Dynamic Load Library

Linux/Unix下动态库文件的后缀名大多是sosdylib。静态库文件后缀名多数是a。windows我不熟悉也不知道也不愿意去查。





准备工作


会写C会写编译命令。





静态库


```
    clang -c log.c -o log.o
    clang -c memory.c -o memory.o
    ar rcs libmylib.a log.o memory.o
```

静态库本质上就是一堆函数的集合,所以把相关文件编译成.o文件之后,就可以使用ar来把这些文件集合成.a文件。ar rcs libmylib.a log.o. memory.o的意思就是把log.omemory.o塞进libmylib.a里面,事实上一个静态库就是各种.o文件的集合。

当你生成好libmylib.a之后,就可以把它加进去了:

```
    clang -o demo.run demo.c -lmylib -L/your/lib/path
    or
    clang -o demo.run demo.c /path/to/your/static/library.a
```



要注意的地方:


  • 要是不加-L来添加库的搜索路径的话,会找不到你的静态库。因为编译器会跑去系统默认的路径去找,然而默认路径并不包括你的当前目录。
  • -l-L要放在demo.c的后面,否则就会报莫名其妙的错误。
  • -l参数会自动帮你在前面补上lib,在后面补上.a
  • 你要是直接用ld来链接也可以,但是ld的参数接口经常会变,所以还是让编译器去链接吧。


其实使用静态库的场合不多,大多数时候是作为第三方提供SDK给别人,但又不希望别人看到源代码,才会用静态库交付。但是在iOS开发领域,静态库用得还是蛮多的,当一个app是由很多业务线组成的时候,最好还是交付静态库而不是代码,这样能节省很多编译的时间。





动态链接库


动态链接库是动态库的一种(先这么区分吧,因为静态库也能动态加载),我们也习惯叫它共享库(Shared Library),当程序加载进内存的时候,动态加载库也会跟着被加载进内存。当动态加载库加载到内存之后,如果后面的程序也起来了,而且也依赖这个动态加载库的话,就不会重复加载。

动态链接库相对于静态库来说更加灵活和复杂,因为在实际应用的时候对动态链接库会有以下要求:


  • 更新动态库之后,依然需要支持那些需要依赖旧版本动态库的程序的正常运行。
  • 当程序运行的时候,需要允许覆盖特定的库,甚至特定的函数。
  • 在程序使用现有库运行时,依然能够需要支持以上两点。


为了达到以上目的,业界制定了一套规范,这套规范主要从两个方面着手:


  • 命名规范。一个动态库会有不同的名字,他们分别起到了不同的作用。
  • 路径规范。一个动态库要放在特定路径下,内核才能够在加载的时候去这个特定路径找到这个动态链接库。




命名规范


一个库有三个分别起到不同作用的名字:sonamereal namelink name。在Mac OSX系统下,soname又被称为install_name



soname


其实就是Shared Object NAME,在MAC OSX下是install_name

这个名字的规范就是lib+库名+so+大版本号,它是用来标示动态链接库的主版本的,用于给内核加载动态库选择版本时提供参考。soname是在编译的时候传递给链接器的,如果你不在编译命令里面设置soname的话,默认会用生成的文件名作为soname,这不是个好的做法,原则上都是要设置一下soname的。编译之后,soname就会被写入到你的库文件里面去,你用vim直接打开库文件就能看到这个库的soname,它跟你ls时候看到的文件名不是同一个东西。

另外,可执行文件具体依赖哪样的库也是在编译的时候被编译进可执行文件的,编译命令会找到对应的库,并把这个库的soname转存到可执行文件的依赖列表中。

举个例子:你有一个库,库名叫做casa,然后当前版本号是1,那么它的soname就应该是libcasa.so.1

soname是怎么起作用的呢,先不着急,我会在实例的地方讲这个。



real name


其实就是真实的文件名,命名规范就是soname+小版本号。它是你动态库的文件名,也就是你ls的时候看到的那个。以上面的libcasa.so.1为例,它的real name就是soname再加小版本号,可以是libcasa.so.1.0或者libcasa.so.1.1这样。

它的具体作用就是让内核加载动态库时使用的名字,所以必须是文件名。



link name


我们在build一个可执行文件的时候,需要在可执行文件里面记录这个可执行文件依赖于哪些动态库,这样内核在加载可执行文件的时候,才知道有哪些动态库需要加载。在写这条编译命令的时候,是不需要带版本号的。

还是举soname是libcasa.so.1的库为例子,它的link name就是libcasa.so。具体编译指令长这样:

```
    clang -o demo.run -c demo.c -L./ -lcasa
```

因为有命名规范,所以链接器会自动在前面加上lib,在后面加上.so,这样我们就只要给出库名字casa就好了。




路径规范


由于动态加载库是动态加载的,当一个可执行文件被执行的时候,系统就要能够找得到它依赖的动态库在哪里。一般情况下,GNU标准要求系统去/usr/local/lib这个地方找。在FHS(Filesystem Hierarchy Standard)中,对这个又做了很多规定:大多数内核依赖的动态库应该被放在/usr/lib下,在内核启动时就依赖的动态库应该放在/lib下,然后不属于内核依赖的动态库才放在/usr/local/lib下。

这两个规范其实不冲突,我们按照最详细的那个来就好了,FHS。




生成shared library和实例


demo.c:

#include "stdio.h"
#include "libcasa.h"

int main(int argc, char *argv[]) {
    return print_a_message("hello world");
}



libcasa.c:

#include "libcasa.h"
int print_a_message(const char *data) {
    int i = 0;

    printf("here i am\n\n");

    for (i = 0; data[i] != '\0'; i++) {
        printf("%c", data[i]);
    }

    printf("\n");

    return 0;
}



libcasa.h:

#include "stdio.h"
int print_a_message(const char *data);



makefile:

shared:
    clang -Wall -fPIC -c libcasa.c -o libcasa.o
    clang -shared -Wl,-install_name,libcasa.so.1 -o libcasa.so.1.0 libcasa.o
    ln -sf libcasa.so.1.0 libcasa.so.1
    ln -sf libcasa.so.1.0 libcasa.so
    clang -Wall -fPIC -g demo.c -o demo.run -lcasa -L./

static:
    clang -c libcasa.c -o libcasa.o
    ar rcs libcasa.a libcasa.o
    clang -o demo.run demo.c libcasa.a

clean:
    rm -rf *.o *.dSYM *.run *.a *.so *.so.1 *.so.1.0



写好这些文件之后,在命令行输入make shared就会生成基于动态链接库的可执行文件demo.run。输入make static就会生成基于静态库的可执行文件demo.run

你会发现,决定一个库是静态还是动态,其实就仅仅取决于它的编译指令的不同。下面我着重解释一下动态库的编译指令。



clang -Wall -fPIC -c libcasa.c libcasa.o


  • -fPIC:这是一个编译器指令,让编译器生成定位无关的代码,具体的定位操作由可执行文件加载时再重定位。要编译动态链接库,必须要有这个参数。-fpic也可以用,但这两者不等价,-fpic生成的文件会比-fPIC小一些,但牺牲了平台兼容性。所以用-fPIC生成的文件大就大一点了,比较保险。



clang -shared -Wl,-install_name,libcasa.so.1 -o libcasa.so.1.0 libcasa.o


  • -shared:告诉编译器你要生成的是一个sharde library,也就是动态加载库。
  • -Wl,install_name,libcasa.so.1:这里就是给你的动态库设置soname的地方。-Wl表示后面跟的字符串是传递给连接器的参数,设置了install_namelibcasa.so.1,这将会出现在编译之后的库文件里面。因为我当前使用的是Mac,如果你用的是Linux的话,就应该是-Wl,soname,libcasa.so.1。对的,install_name就是soname,不同的系统不同而已。编译完之后,你可以使用vim直接打开libcasa.so.1.0,你会找到libcasa.so.1这个字样。如果你不传递这个参数也能编译通过,它就会把文件名作为soname。但是原则上不能省略这个soname的配置,它会引起库版本管理错乱。
  • -o libcasa.so.1.0:这就是动态库的real name,表示生成出来的库的文件名为libcasa.so.1.0,但它的soname是libcasa.so.1哦。
  • ln -sf libcasa.so.1.0 libcasa.so.1:这里做了一个符号链接,把soname跟real name做了关联。这样在程序运行要加载动态库的时候,它会去加载libcasa.so.1,由于有了符号链接,实际上内核会加载libcasa.so.1.0,以后如果有更新的版本,但soname还是没变的话,内核就能自动加载最新版本了。
  • -sf libcasa.so.1.0 libcasa.so:这里做了一个符号链接,把link name跟real name做了关联。这样在后面的-lcasa的时候,就会按照命名规范组装成libcasa.so,然后找到libcasa.so做链接。



clang -Wall -fPIC -g demo.c -o demo.run -lcasa -L./

  • -fPIC:这里的-fPIC其实不写也可以,但是GCC4.5的手册要求你最好还是写上,具体原因倒是没细说。
  • -lcasa:编译器会通过这条指令组装动态库的link name,在前面加上lib,在后面加上.so,最终形成libcasa.so,编译器就会根据这个名字找到这个库,然后把这个动态库的soname读取出来,加到可执行文件的依赖列表里面去。
  • -L./:告诉编译器搜索动态库的地址在当前目录



这时我们终于可以强调一下soname的规范了:


  • 如果你更新的动态库内容变化并没有添加或删除API,只是修改了API的实现,那么soname可以不用变,只要改变你的动态库的real name就好。然后把对应soname做符号链接到你的新版本动态库中,下次启动这个可执行文件时,内核就会加载到最新的动态库了。
  • 如果你更新的动态库里面有添加新的API,可以再在soname后面添加一个小版本号,soname就可以变成libcasa.so.1.1。即使原来的可执行文件还是依赖于libcasa.so.1,那也不影响使用。
  • 如果你更新的动态库已经不兼容就版本了,那么soname后面的数字就要改改了,比如改成libcasa.so.2

所以你一定要给你的动态库在编译的时候设置soname,不然soname就跟着文件名走了,到后面涉及版本管理的时候你就坑了。

MAC下直接跑生成成功的demo.run没问题,Linux下要做一些额外的操作,具体看下面的小贴士。




小贴士


  • Linux下make shared之后要想正常运行demo.run,还需要一些额外的操作


你需要把你的libcasa.so.1(real name)拷贝到/usr/local/bin下面,然后运行一次ldconfig。ldconfig帮你建立了一些必要的符号链接,刷新了你的ld.so.cache,然后你再跑demo.run就没问题了。

另外,我习惯在/usr/local/lib里为单独的一套库创建新的文件夹来存放,比如我会新建一个libcasa这个文件夹,把跟libcasa相关的库都放到这里面去。这么做的话,在install的时候就需要在/etc/ld.so.conf里面添加一行/usr/local/lib/libcasa,然后再跑ldconfig了。这么做的好处就是/usr/local/lib看上去很干净。

因为去不同的路径下面找各自的动态库很影响效率,所以Linux专门有这个cache来记录动态库都在哪儿,这样加载的时候就快了。发展到后面就变成,你不建立这个cache,内核干脆就不找了,直接跟你说没这个动态库。



  • 在Linux下使用ldd命令能够看出一个可执行文件它依赖于哪些动态库,在Mac下使用otool -L来查看:


首先是忠告:你不要拿ldd去看你不信任的可执行文件,因为它会执行这个可执行文件。

这里是采用动态链接库方案编译的结果:

 $  make shared 
    clang -Wall -fPIC -c libcasa.c -o libcasa.o
    clang -shared -Wl,-install_name,libcasa.so.1 -o libcasa.so.1.0 libcasa.o
    ln -sf libcasa.so.1.0 libcasa.so.1
    ln -sf libcasa.so.1.0 libcasa.so
    clang -Wall -fPIC -g demo.c -o demo.run -lcasa -L./

 $  otool -L demo.run 
    demo.run:
    libcasa.so.1 (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1213.0.0)

可以看到demo.run这个可执行文件依赖于libcasa.so.1这个soname的库。下面是采用静态库方案编译的结果:

 $  make static 
    clang -c libcasa.c -o libcasa.o
    ar rcs libcasa.a libcasa.o
    clang -o demo.run demo.c libcasa.a
 $  otool -L demo.run 
    demo.run:
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1213.0.0)

可以看到此时demo.run这个可执行文件并不依赖于libcasa.so这个动态库,因为它已经被静态编译进这个可执行文件了。



  • 使用nm能够看到一个库里面都有些啥。


    ➜ $  nm libcasa.so.1.0 
        0000000000000ed0 T _print_a_message
                         U _printf
                         U dyld_stub_binder



  • 使用/etc/ld.so.preload来覆盖一些函数


你可以把针对某个库中某些函数的实现编译成.o文件,填进去。不过我试了几次没有成功,这种做法一般是在调试bug的时候会用到,发行出去的动态库要杜绝这种做法。





动态加载库


动态加载库(Dynamic Load Library)是在程序运行时,由程序自己调用系统API去加载的,而不是内核加载程序时顺便加载的。本质上跟静态库和动态链接库没什么区别,只是使用方式有所不同而已。由于可以由程序自主调用,这种方案使得插件机制和动态模块机制的实现成为了可能。



涉及的API


#include <dlfcn.h>

void* dlopen(const char* path, int mode);
void* dlsym(void* handle, const char* symbol);
int dlclose(void* handle);
char* dlerror(void);

Link with -ldl

大致流程就是先dlopen一个库,然后用dlsym从这个库里面根据函数名找到对应的函数,然后返回一个指向这个函数的指针,拿到这个指针直接调用就好了。库用完之后调用dlclose关闭就可以。记得要在编译可执行文件时添加一个-ldl参数,其他就没什么了。

man里面写得非常好,还有用例,我就不搬运了。





结束


这篇文章主要讲了如何去实现不同类型的库,IBM的知识库也有一篇类似的文章着重介绍了shared library,这篇文章的文末提供了一些其他文章,那里有很多更深的内容,推荐大家去看一下。





❌
❌