Facebook model 库 Remodel 观感

Linus Torvalds 有句名言:”Bad programmers worry about the code. Good programmers worry about data structures and their relationships.”

虽然不知道自己算不算的上是 “Good programmer”,但我对数据的重要性是深有体会,之前也写过几篇与 model 相关的技术文章,最近看一些历史问题代码的时候,又想起一些和 model 相关的知识点,觉得可以结合 Remodel 这个 framework 再谈一下 iOS 项目里 model 处理。

在开始之前,大家可以做个小测验,回想下最近的一个项目里,大概有多少个 model 的定义存在,位于不同 module 的 model 之间又是如何转换交互的。如果对业务熟悉,那么有多少 model 存在应该了如指掌,如果 App 的结构清晰,理清 model 的层次和流向就不会太难。

Remodel 是 facebook 去年开源的项目,主要解决两个大方向的问题,一是 model 相关的大量重复代码,二是降低 model 在架构上所附带的代码耦合。

第一个问题对有些项目来说可能都不是问题,有些工程师对于 model 的处理和一般 class 对象没有太大的区别,无非是按需要增加 property 和与之相关的函数。model 与一般对象之间最大的差别在于 model 是信息的载体,其中又包含若干数据类型,而数据一旦存在于较长的时间跨度和较大的空间范围,model 就有了状态,状态之间或有依赖,业务逻辑也大多是围绕状态展开,状态维护出错必然会导致各类奇怪 bug。

真要写好一个 model 类,不可避免的会写大量的重复代码,对于 model 的约束越多,代码就越可控,代码量也会随之增加。

避免手写重复代码

按照 facebook 总结,一个 model 可能会包含如下代码:

重载 description 代码,debug 时方便调试。

- (NSString *)description
{
  return [NSString stringWithFormat:@"%@ - \n\t userId: %tu; \n\t nickname: %@; \n\t imageUrl: %@; \n", [super description], _userId, _nickname, _imageUrl];
}

实现 NSCoding protocol,方便持久化到 disk 中。

- (void)encodeWithCoder:(NSCoder *)aCoder
{
  [aCoder encodeInteger:_userId forKey:@"userIdKey"];
  [aCoder encodeObject:_nickname forKey:@"nicknameKey"];
  [aCoder encodeObject:_imageUrl forKey:@"imageUrlKey"];
}

实现 immutable model。

数据的 immutability 是个大话题,牵涉到整个 App 的架构和数据的流动。具体到 model 上,我们一般将 property 设置为 readonly,提供专门的 init 方法来初始化对象,将改变对象状态的机会限制在创建的时候,对象一旦创建好,即使在多个 module 之间传递使用,也不会随意发生状态的变化,如果要改变 model 中某个 property 的值,只能创建一个新的对象,然后再覆盖 cache 中的旧 model 对象。所以这里往往需要写一个到多个便捷的 init 方法来创建对象。(之前还写过一篇关于 model 创建的文章

@interface User : NSObject
@property (nonatomic, readonly) NSUInteger userId;
@property (nonatomic, readonly) NSString *nickname;
@property (nonatomic, readonly) NSURL *imageUrl;

- (instancetype)initWithUserId:(NSUInteger)userId
                      nickname:(NSString *)nickname
                      imageUrl:(NSURL *)imageUrl;
@end

到这里已经不难发觉一个 model 中,重复书写 property 的场景有不少,依据代码架构的不同,实际还存在更多的场景,比如我们为了避免共享 model 对象的同一个内存拷贝,往往需要实现 NSCopying protocol 来方便创建语义上等同的对象:

@protocol NSCopying
- (id)copyWithZone:(nullable NSZone *)zone;
@end

又比如,不同的 module 之间为了降低耦合度,往往针对同一份业务数据,有自己的 model 定义,此时我们需要 model 之间相互转化的 init 方法:

@interface User : NSObject
@property (nonatomic, readonly) NSUInteger userId;
@property (nonatomic, readonly) NSString *nickname;
@property (nonatomic, readonly) NSURL *imageUrl;

- (instancetype)initWithNetUser:(NetUser*)user;
@end

一旦大量的代码需要重复书写,不但无谓的损耗了程序员精力,出错的概率也会随之增加。Remodel 解决这个问题的方式,是通过脚本来生成相关代码,工程师只需要定义 model 的 prototype,在加上一些特殊的功能性标签,即可生成不会出错的标准化的 model 类,这里头技术含量并不算高,但用脚本提升效率降低错误率的工程化思维方式很值得学习。实际上,我们平时针对新业务,新建一个 Controller 的时候,总会有不少代码是机械式重复的,MVP 也好,MVVM 也好,一旦定义好设计,使用脚本自动生成一个 Controller 模块里通用的代码,能提升我们平时的开发效率。

降低 model 所带来的耦合度

Remodel 的另一个设计理念是,使用 simple model 来降低耦合度。

我们可以先看一下 model 的职责。一个 model 到底应该承担多少职责,一直是个存在争议的话题。一种极端是只包含数据的 model(只有少量的 property),另一中极端是除了数据之外,还包含大量与之相关的业务逻辑代码,形成 fat model(包含大量 property 和函数)。我看过的更多的真实场景是工程师随意而为,按个人喜好随意在 model 中添加自认为相关的业务属性和逻辑代码,最后慢慢也走向 fat model 的极端形式。

这个问题的另一个问法是:我们的业务代码放在那里更好?依我所见可以归为三类:

  1. 放在 model 中。
  2. 放在 Controller 或者 Presenter 中。
  3. 放在独立的功能模块中,比如 xxService,xxManager。

放在哪个位置更优,从设计的角度来说难有统一的标准,但以下两点,按我的经验,是可以特意避免的错误做法:

三个位置随意放置业务代码,没有统一的规范。这种做法最大的坏处是,程序员 A 接手程序员 B 代码的时候,需要一段阅读时间来适应,或者程序员 A 回过头看自己写的时间久远代码的时候,也需要热身的时间,不能直接上手 debug。如果一个团队规范清晰,代码结构合理,那么所有成员写的代码,无论是在代码风格,还是流程处理上都是高度接近的。

业务代码过度集中在一个位置,形成 fat class。这种做法的坏处,参与过成熟项目的同学应该都能体会,我肉眼所见的记录是一个 Controller 文件里有大概 1.5w 行代码。fat class 不仅阅读困难,而且会带来代码结构的持续恶化。从这个角度来说,我们应该尽量让 model 里的代码量少一些,至于是只包含数据,还是允许少量业务函数存在,我认为二者皆可,但我个人更倾向于后者,让 model 承担少量和数据紧密相关的业务代码。

比如一个 User 类中可以包含如下逻辑:

@interface User : NSObject
@property (nonatomic, readonly) NSUInteger userId;
@property (nonatomic, readonly) NSString *nickname;
@property (nonatomic, readonly) NSURL *imageUrl;

- (BOOL)isNicknameValid;
- (BOOL)isUserAvatarDownloaded;
@end

但什么样的逻辑是和数据紧密相关的呢,这的确是一个偏主观的判断,十分考验团队成员之间的默契,团队成员磨合的越久,大家对于什么样的代码该放到什么样的位置,就越容易达成一致的意见。

Remodel 提倡在代码的设计上,使用 simple model,所谓的 simple model 是将数据与逻辑隔离开来,让 model 只承担信息载体的职责,将与之相关的逻辑放到专门的功能类当中。这种做法就是我上面提到的第一种极端(非贬义词)。好处是数据与行为分离开来,可以工程师 A 定义数据,工程师 B 写与之相关的业务代码,并行开发,提升效率,而且业务代码都在特定的功能类里,定位也容易,不用去一个个 model 中搜索。比如一个 Message 对象定义好后,如果要发送消息,就建立一个 MessageSender 来执行相关逻辑。

这种 simple model 的做法也存在一些潜在的问题。首先是功能类的管理,功能类如何命名呢?别笑,我见过非常多的命名困难户,甚至包含一些逻辑能力强悍的老司机,就是无法取出一个简洁贴切的好名字,很多人都只能在 helper,manager,util,handler 这里面挑选。MessageManager 和 MessageSender 哪个更好是显而易见的。另外我们到底需要多少个与 model 相关的功能类呢?MessageSender 之后是不是还有 MessageKeeper,MessageFormatter 等等 MessageXXX,这个粒度如何把控呢?再者这些功能类如何分门别类的放置在合理的位置,也会是一个问题。

model 从面向对象的角度来看的话,它是应该承担一些行为代码的,Martin Fowler 有一段关于 POJO 的阐述,说的是类似的意思:

An acronym for: Plain Old Java Object.

The term was coined while Rebecca Parsons, Josh MacKenzie and I were preparing for a talk at a conference in September 2000. In the talk we were pointing out the many benefits of encoding business logic into regular java objects rather than using Entity Beans. We wondered why people were so against using regular objects in their systems and concluded that it was because simple objects lacked a fancy name. So we gave them one, and it’s caught on very nicely.

我个人也习惯在 model 中写一些简单的行为代码。让 model 适当的承担一些行为代码,可以降低其他业务类的压力,均衡复杂度。

Remodel 之所以使用 simple model 的另一个原因,我个人猜测是由于通过工具生成的 model 类,其中的业务代码会被覆盖,二者比较难以在设计上共存。

Plugin 拓展

Remodel 本身只提供了最常用的代码生成工具,开发人员完全可以在 Remodel 的基础之上,实现符合自身架构特色的代码生成方式。

Remodel 允许开发者使用 TypeScript,以 Plugin 的方式去对 Remodel 做深度定制。

写代码的顺序

写代码有时候看起来和盖楼特别相似,盖楼都是先打地基,而后自下而上的逐步完善。在处理新项目架构的时候,特别是对 model 重度依赖的项目(业务模块多、大量和 server 的数据交互、大量的数据持久化需求、数据之间存有依赖关系),我个人习惯都是先和产品深度讨论业务形态,之后设计好 model,处理好 model 之间的关联,再往上写 model 的持久化,和 server 的数据交互等等,”地基”搭好之后,再从 Controller 开始写,逐个攻克业务模块。这种做法也是强调数据的重要性,把 model 定义好,处理好 model 之间的关联,model 的转化与流向, 这些工作预先做好了,基础就牢固,业务做起来也不容易出错。

结束语

关于 Remodel 就介绍到这。设计上的东西比较容易产生争议,以上的观点都是一家之言,仅供参考之用。WWDC 2017 马上就要开幕啦,到时候会以 iOS 工程师的视角写几篇短文和大家分享下。


Hosted by Coding Pages