iOS 应用架构 (二)

iOS 客户端应用架构看似简单,但实际上要考虑的事情不少。本文作者将以系列文章的形式来回答 iOS 应用架构中的种种问题,本文是其中的第二篇,主要讲 View 层的组织和调用方案。上篇主要讲 View 层的代码结构、布局,以及一些最佳实践的讨论。

当我们开始设计 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 的代码应该差不多是这样:

要点如下:

所有的属性都使用 getter 和 setter

不要在 viewDidLoad 里面初始化你的 view 然后再 add,这样代码就很难看。在 viewDidload 里面只做 addSubview 的事情,然后在 viewWillAppear 里面做布局的事情(这里在最后还会讨论),最后在 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,另外一个就是 StoryBoard 或 XIB。因为这些文件的内容的可读性非常差,虽然苹果在 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,但它也是有适用范围的,在适用继承的地方,还是要老老实实使用继承。比如你有一个数据模型,是由基本模型派生出的一整套模型,那么这个时候还是老老实实使用继承。至于拿捏何时使用继承,相信各位架构师一定能够处理好,或者你也可以参考我前面提到的那篇文章来控制拿捏的尺度。

关于在哪儿写 Constraints?

其实在 viewWillAppear 这里改变 UI 元素不是很可靠,Autolayout 发生在 viewWillAppear 之后,严格来说这里通常不做视图位置的修改,而用来更新 Form 数据。改变位置可以放在 viewWilllayoutSubview 或者 didLayoutSubview 里,而且在 viewDidLayoutSubview 确定 UI 位置关系之后设置 autoLayout 比较稳妥。另外,viewWillAppear 在每次页面即将显示都会调用,viewWillLayoutSubviews 虽然在 lifeCycle 里调用顺序在 viewWillAppear 之后,但是只有在页面元素需要调整时才会调用,避免了 Constraints 的重复添加。

苹果在文档中指出,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];
}

这个做法是目前我自己觉得可能比较合适的做法,当然也欢迎其他同学继续拿出自己的看法,我们来讨论。

编后语

为了更好地向读者输出更优质的内容,InfoQ 将精选来自国内外的优秀文章,经过整理审校后,发布到网站。本篇文章作者为田伟宇,原文链接为Casa Taloyum。本文已由原作者授权 InfoQ 中文站转载。


感谢徐川对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群

posted @ 2019-04-10 20:48  SoulDu  阅读(253)  评论(0编辑  收藏  举报