前言
如果app一开始没有考虑架构设计,所有代码都在一个仓库里,随着业务的不断迭代,代码越来越多,逻辑越来越臃肿,问题也就来了。
- 如果一个人提交有问题,所有人都要等到他修复错误提交后才能重新拉取代码。可以通过分支管理的办法解决,每个人开发的功能单独拉取分支开发,合并到主分支前必须要进行review request。但是确实难以避免经过pr的代码仍然有问题😂。
- 代码耦合严重,边界不清楚,也影响到了问题排查的效率,并增加了沟通时间;还会导致不同的人开发不同的功能模块需要用到相同或者类似的功能时,没发复用基础功能模块,导致不必要的重复劳动,造成浪费,也增加了出错的概率,同时不利于后期的维护(需要维护多分功能类似的代码)
谈到这里,就需要架构治理。也就是需要将老业务、老代码按照新的架构设计模式进行重构。所以,架构重构考虑得越晚,重构起来就越困难,快速迭代的需求开发和漫长的重构之间的矛盾,如同在飞行的飞机上换引擎。及早考虑架构设计就显得尤为重要。
如何设计一个支持大型APP的架构?
有哪些App架构模式
参考iOS架构模式(MVC/MVCS/MVP/MVVM/VIPER) - 简书
- MVC,苹果官方推荐的 App 开发模式是 MVC,其他架构模式都是由它衍生出来的
缺点:并没有区分业务逻辑和业务展示, 这对单元测试很不友好
MVP针对以上缺点做了优化, 它将业务逻辑和业务展示也做了一层隔离。
参考:【iOS】MVC模式_瓯海剑的博客-CSDN博客_ios mvc模式
- MVCS
- S:store
- 瘦model和controller
- MVP:MVP模式是MVC模式的一个演化版本,MVP全称Model-View-Presenter。
优点:
任务均摊 :我们将最主要的任务划分到 Presenter 和 Model,而 View 的功能较少;
可测试性 : 非常好,由于一个功能简单的 View 层,所以测试大多数业务逻辑也变得简单;
易用性 : 代码量比 MVC 模式的大,但同时 MVP 的概念却非常清晰。
接耦合:模型与视图完全分离,我们可以修改视图而不影响模型。
可以更高效地使用模型,因为所有的交互都发生在一个地方 —— Presenter内部。
可复用性:我们可以将一个Presenter用于多个视图,而不需要改变Presenter的逻辑。这个特性非常的有用,因为视图的变化总是比模型的变化频繁。
缺点:
视图和Presenter的交互会过于频繁,使得他们的联系过于紧密。也就是说,一旦视图变更了,Presenter也要变更。
MVVM模式针对MVP做了优化,利用视图绑定机制,使Presenter不直接持有View,将View和业务逻辑层完全隔离。
参考:【iOS】MVP模式_kochunk1t的博客-CSDN博客_ios mvp
- MVVM
- Model: 数据层,业务逻辑处理、数据控制(本地数据、网络加载数据)。
- ViewController/View: 展示层,显示用户可见得视图控件、与用户交互事件。界面的生命周期控制和业务间切换控制。
- ViewModel: 数据模型,是组织生成和维护的视图数据层。在这一层开发者对从后端获取的 Model 数据进行转换处理,做二次封装,以生成符合 View 层使用预期的视图数据模型。
注意事项:View持有ViewModel,反之不行;ViewModel持有Model,反之不行;ViewModel在View和Model间建立绑定关系,当数据变化时,视图自动更新;当视图刷新时,数据自动刷新。
优点:
低耦合: View 可以独立于Model变化和修改,一个 viewModel 可以绑定到不同的 View 上
可重用性: 可以把一些视图逻辑放在一个 viewModel里面,让很多 view 重用这段视图逻辑
独立开发: 开发人员可以专注于业务逻辑和数据的开发 viewModel,设计人员可以专注于页面设计
可测试: 通常界面是比较难于测试的,而 MVVM 模式可以针对 viewModel来进行测试
双向绑定数据
缺点:
1. 数据绑定使得Bug 很难被调试。你看到界面异常了,有可能是你 View 的代码有 Bug,也可能是 Model 的代码有问题。
2. 数据绑定使得一个位置的 Bug 被快速传递到别的位置,要定位原始出问题的地方就变得不那么容易了。
- 对于过大的项目,数据绑定需要花费更多的内存。
参考:【iOS】MVVM模式_kochunk1t的博客-CSDN博客
- VIPER
- 视图(View):根据展示器的要求显示界面,并将用户输入反馈给展示器。
- 交互器(Interactor):包含由用例指定的业务逻辑。
- 展示器(Presenter):包含为显示(从交互器接受的内容)做的准备工作的相关视图逻辑,并对用户输入进行反馈(从交互器获取新数据)。
- 实体(Entity):包含交互器要使用的基本模型对象。
- 路由(Router):包含用来描述屏幕显示和显示顺序的导航逻辑
优点:
- 任务均摊 – 毫无疑问,VIPER是任务划分中的佼佼者。
- 可测试性 – 不出意外地,更好的分布性就有更好的可测试性。
- 易用性 – 最后你可能已经猜到了维护成本方面的问题。你必须为很小功能的类写出大量的接口。
架构设计需要考虑的三个问题
模块粒度划分、如何分层、多团队如何协作,带着这三个问题,再进一步探讨。
碰到哪些问题
项目规模变大后,模块划分必须遵循一定的原则。如果模块划分规则不规范、不清晰,就会导致代码耦合严重的问题,并加大架构重构的难度。这些问题主要表现在:
- 业务需求不断,业务开发不能停。重新划分模块的工作量越大,成本越高,重构技改需求排上日程的难度也就越大。
- 老业务代码年久失修,没有注释,修改起来需要重新梳理逻辑和关系,耗时长。
划分的标准(模块粒度划分)
划分的标准应该遵循以下五个原则,即 SOLID 原则:
- 单一功能原则:对象功能要单一,不要在一个对象里添加很多功能。
- 开闭原则:扩展是开放的,修改是封闭的。
- 里氏替换原则:子类对象是可以替代基类对象的。
- 接口隔离原则:接口的用途要单一,不要在一个接口上根据不同入参实现多个功能。
- 依赖反转原则:方法应该依赖抽象,不要依赖实例。iOS 开发就是高层业务方法依赖于协议(这句话不是太理解)。
遵守这五个原则是开发出容易维护和扩展的架构的基础。
划分的粒度大小
组件可以认为是可组装的、独立的业务单元,具有高内聚,低耦合的特性,是一种比较适中的粒度。就像用乐高拼房子一样,每个对象就是一块小积木。一个组件就是由一块一块的小积木组成的有单一功能的组合,比如门、柱子、烟囱。在老师看来,iOS 开发中的组件,不是 UI 的控件,也不是 ViewController 这种大 UI 和功能的集合。因为,UI 控件的粒度太小,而页面的粒度又太大。iOS 组件,应该是包含 UI 控件、相关多个小功能的合集,是一种粒度适中的模块。这个观点,我也深表赞同,划分粒度要达到一个艺术的平衡,这其实是组件化里很关键的一部。
具体步骤参考
1.拖文件
采用组件的话,对于代码逻辑和模块间的通信方式的改动都不大,完成老代码切换也就相对容易些。我们可以先按照物理划分,也就是将多个相同功能的类移动到同一个文件夹下,然后做成 CocoaPods 的包进行管理。(我们组件化第一部也是这么做,先拖文件)
2.解耦(如何分层)
做到第一步肯定还不够,因为功能模块之间的耦合还是没有被解除。如果没有解除耦合关系的话,不同功能的开发还是没法独立开来,勉强开发完成后的影响范围评估也难以确定。
接下来,我们就需要重新梳理组件之间的逻辑关系,进行改造。
组件解耦并不是说要求每个组件间都没有耦合,组件间也需要有上下层依赖的关系。组件间的上下层关系划分清楚了,就会容易维护和管理。而对于组件间如何分层这个问题,层级最多不要超过三个,可以这么设置:底层可以是与业务无关的基础组件,比如网络和存储等;中间层一般是通用的业务组件,比如账号、埋点、支付、购物车等;最上层是迭代业务组件,更新频率最高。(我上家公司的组件化就是这么分层的,相信大多数都是这么分层的,有其他分层的方式,欢迎评论区交流)
- 底层可以是与业务无关的基础组件,比如网络和存储等;
- 中间层一般是通用的业务组件,比如账号、埋点、支付、购物车等;
- 最上层是迭代业务组件,更新频率最高。
不用把所有的功能都做成组件,只有那些会被多个业务或者团队使用的功能模块才需要做成组件。因为,改造成组件也是需要时间成本的,很少有公司愿意完全停下业务去进行重构,而一旦决定某业务功能模块要改成组件,就要抓住机会,严格按照 SOLID 原则去改造组件,因为返工和再优化的机会可能不会再有。
3.团队人员之间分工(多团队如何协作)
项目很大时,组件的改造和维护肯定不是一个人能轻松搞定的,这时候就需要合理安排团队的成员一起来维护和开发。
- 基建团队:负责业务无关的基础功能组件和业务相关通用业务组件的开发。
- 专门的业务团队:业务可以按照功能耦合度来划分,耦合度高的业务可以划分成单独的业务团队。
- 基建团队人员应该是流动的,从业务团队里来,再回到业务团队中去。这么设计是因为业务团队和基建团队的边界不应该非常明显,否则就会出现基建团队埋头苦干,结果可能是做得过多、做得不够,或着功能不好用的问题,造成严重的资源浪费。
总结来讲,就是团队分工要灵活,不要把人员隔离固化了,否则各干各的,做的东西相互都不用。核心上,团队分工还是要围绕着具体业务进行功能模块提炼,去解决重复建设的问题,在这个基础上把提炼出的模块做精做扎实。(题外话:我上家公司的团队合作分工就还蛮合理,业务层大家互相渗透;基础层由主要负责的开发,大家一起维护。这样的好处是团队不会因为某个人而卡壳,做出来的组件也更利于使用;坏处是每个人都是容易被替代的,但是对个人来说接触到的业务广泛,能吸收到更丰富的知识,长远来说是有益的)
组建间关系协调的两种设计方案
协议式架构
采用的是协议式编程的思路,在编译层面使用协议定义规范,实现可在不同地方,从而达到分布管理和维护组件的目的。这种方式也遵循了依赖反转原则,是一种很好的面向对象编程的实践。(这里我的理解有些不一样,协议式编程思路POP跟面向对象编程AOP不是一回事,侧重点是不一样的,swift就是侧重协议式编程的一门语言,参考【转】Swift 中的面向协议编程:引言_程序员华仔的博客-CSDN博客_swift 面向协议编程)
缺点:
- 由于协议式编程缺少统一调度层,导致难于集中管理,特别是项目规模变大、团队变多的情况下,架构管控就会显得越来越重要。
- 协议式编程接口定义模式过于规范,从而使得架构的灵活性不够高。当需要引入一个新的设计模式来开发时,我们就会发现很难融入到当前架构中,缺乏架构的统一性。(这里我的理解是,协议编程确实没发统一管理,加入需要更换一种架构模式,确实成本很高)
从上图看,组件间的通信,依赖于协议。确实不能做到调用的统一管理,但是使用起来确实是比较简单,而且轻量的。
中间者架构
采用中间者统一管理的方式,来控制 App 的整个生命周期中组件间的调用关系。(问题:如何控制整个App的生命周期?)
从上图看出来,组件的通信依赖于中间者,做到了统一管理,易于后期维护和管理。
路由管理方式
中间者模式,其实就是路由管理方式。参考iOS 组件化 —— 路由设计思路分析 - 走看看
有如下几种路由设计方案
1. JLRoutes Star 3189
JLRoutes是受URL Scheme思路的影响。它把所有对资源的请求看成是一个URI。
2. routable-ios Star 1415
Routable路由是用在in-app native端的 URL router, 它可以用在iOS上也可以用在Android上。
3. HHRouter Star 1277
这是布丁动画的一个Router,灵感来自于 ABRouter 和 Routable iOS。
4. MGJRouter Star 633
这是蘑菇街的一个路由的方法。
这个库的由来:
JLRoutes 的问题主要在于查找 URL 的实现不够高效,通过遍历而不是匹配。还有就是功能偏多。HHRouter 的 URL 查找是基于匹配,所以会更高效,MGJRouter 也是采用的这种方法,但它跟 ViewController 绑定地过于紧密,一定程度上降低了灵活性。于是就有了 MGJRouter。
从数据结构来看,MGJRouter还是和HHRouter一模一样的。
蘑菇街为了区分开页面间调用和组件间调用,于是想出了一种新的方法。用Protocol的方法来进行组件间的调用。也就是上面👆提到的协议式协调组件的方式。
5. CTMediator Star 803
主要思想是利用了Target-Action简单粗暴的思想,利用Runtime解决解耦的问题。
6. Riblets(肋骨)
这个方案是不开源的,是Uber 骑手App的一个方案。
Uber在发现MVC的一些弊端之后:比如动辄上万行巨胖无比的VC,无法进行单元测试等缺点后,于是考虑把架构换成VIPER。但是VIPER也有一定的弊端。因为它的iOS特定的结构,意味着iOS必须为Android做出一些妥协的权衡。以视图为驱动的应用程序逻辑,代表应用程序状态由视图驱动,整个应用程序都锁定在视图树上。由操作应用程序状态所关联的业务逻辑的改变,就必须经过Presenter。因此会暴露业务逻辑。最终导致了视图树和业务树进行了紧紧的耦合。这样想实现一个紧紧只有业务逻辑的Node节点或者紧紧只有视图逻辑的Node节点就非常的困难了。
通过改进VIPER架构,吸收其优秀的特点,改进其缺点,就形成了Uber 骑手App的全新架构——Riblets(肋骨)。
Riblets(肋骨)内的Router不再是视图逻辑驱动的,现在变成了业务逻辑驱动。这一重大改变就导致了整个App不再是由表现形式驱动,现在变成了由数据流驱动。
案例分析
CTMediator 使用的是运行时解耦(我的理解CTMediator其实就是实现了一个命名路由,让模块之间完全隔离开来)
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
{
NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];
// generate target
NSString *targetClassString = nil;
if (swiftModuleName.length > 0) {
targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
} else {
targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
}
NSObject *target = self.cachedTarget[targetClassString];
if (target == nil) {
Class targetClass = NSClassFromString(targetClassString);
target = [[targetClass alloc] init];
}
// generate action
NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
SEL action = NSSelectorFromString(actionString);
if (target == nil) {
// 这里是处理无响应请求的地方之一,这个demo做得比较简单,如果没有可以响应的target,就直接return了。实际开发过程中是可以事先给一个固定的target专门用于在这个时候顶上,然后处理这种请求的
[self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
return nil;
}
if (shouldCacheTarget) {
self.cachedTarget[targetClassString] = target;
}
if ([target respondsToSelector:action]) {
return [self safePerformAction:action target:target params:params];
} else {
// 这里是处理无响应请求的地方,如果无响应,则尝试调用对应target的notFound方法统一处理
SEL action = NSSelectorFromString(@"notFound:");
if ([target respondsToSelector:action]) {
return [self safePerformAction:action target:target params:params];
} else {
// 这里也是处理无响应请求的地方,在notFound都没有的时候,这个demo是直接return了。实际开发过程中,可以用前面提到的固定的target顶上的。
[self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
[self.cachedTarget removeObjectForKey:targetClassString];
return nil;
}
}
但是,这种运行时直接硬编码的调用方式也有些缺点,主要表现在两个方面:
- 直接硬编码的调用方式,参数是以 string 的方法保存在内存里,虽然和将参数保存在 Text 字段里占用的内存差不多,同时还可以避免.h 文件的耦合,但是其对代码编写效率的降低也比较明显。
- 由于是在运行时才确定的调用方法,调用方式由 [obj method] 变成 [obj performSelector:@""]。这样的话,在调用时就缺少类型检查,是个很大的缺憾。因为,如果方法和参数比较多的时候,代码编写效率就会比较低。
如何改进
调用者是不会直接调用 CTMediator 的方法的。那调用者怎么发起调用呢?通过响应者给 CTMediator 做的 category 或者 extension 发起调用。
category 或 extension 以函数声明的方式,解决了参数的问题。调用者看这个函数长什么样子,就知道给哪些参数。在 category 或 extension 的方法实现中,把参数字典化,顺便把 target、action 这俩字符串写死在调用里。于是,对于调用者来说,他就不必查文档去看参数怎么给,也不必担心 target、action 字符串是什么了。
也就是在中间者的基础上,针对每个组件包装一层华美的更利于使用的category或者extention,供使用者调用。而且,因为CTMediator抹去了不同语言之间的隔阂,可以让不同语言之间的组件也可以无差别互相使用。
如上图,这样做也很优雅的解决了 中间层因为组件过多而过于臃肿的问题。
解耦的精髓在于业务逻辑能够独立出来,并不是形式上的解除编译上的耦合(编译上解除耦合只能算是解耦的一种手段而已)。所以,在考虑架构设计时,我们更多的还是需要在功能逻辑和组件划分上做到同层级解耦,上下层依赖清晰,这样的结构才能够使得上层组件易插拔,下层组件更稳固。而中间者架构模式更容易维护这种结构,中间者的易管控和易扩展性,也使得整体架构能够长期保持稳健与活力。
易管控和易扩展性
CTMediator 的基础上进行了扩展实例 GitHub - ming1016/ArchitectureDemo: ArchitectureDemo,基于 CTMediator 扩展
利用了如下特性:链式调用,观察者模式,工程模式,状态机
写在最后,关于架构,抛开业务谈架构是没有意义的。因为架构是为了业务服务的,空谈架构只是一种理想的状态。所以没有最好的方案,只有最适合的方案。