Skip to content

Learn:更轻量的 View Controllers

原文链接:ObjC中国-更轻量的ViewControllers

本文是对ObjC期刊一系列高质量博文所作的个人学习笔记。

本文约 3,580 字,可能需要 15 分钟时间阅读。

本文所用到的示例DEMO

created: 2018-12-16

update: 2018-12-17 增加issue1-2链接

update: 2022-01-28 迁移至 github.io,调整链接,增补内容

2022-01-28:最佳实践

回头来看以前的垃圾文章,一堆废话,删就不删了,补充点东西。说那么多,本质是一个推导出 vc 写法最佳实践的过程。

protocol

现在主要使用 Swift 开发后,protocol 能力增强,cell 解耦的写法大大简化,目前项目组内原则上是要求任意一个 cell 都有对应的 protocol,而 vc 内的 delegate 和 dataSource 分离反而不常见,因为往往 delegate 和 dataSource 依赖 vc. viewModel,而我们会将绝大部分逻辑放在 vc.viewModel 内;分离做法也可以参见苹果官方 DEMO AVCam: Building a Camera AppPhotoCaptureProcessor 类的实现。另一个容易搞混的点是 protocol 主要是为了复用还是解耦:我认为解耦的意义 >> 复用,一旦 vo 或者 vm 出现调整,protocol 形式的 cell 会好调整得多,这是使用 protocol 的主要目的;如果想做成公共组件,那就应该单独写,或者从业务模块里提出来,不要思考和实践业务模块里直接捞 cell 用的情况;高复用的组件往往包含了 protocol 模式,但不是用了 protocol 就等于可复用。

viewModel

原则上一个 vc 必须有一个对应的 vm,所有东西能移的都移到 viewModel 里,主要包含网络请求,页面数据和业务逻辑三块,vc 更多的是传参赋值操作。绝不完美,但肯定够用,一个移动端 SaaS 系统的业务复杂度不能算低了。

viewController 数据传递

传 id >> 传 model,进下个页面通过单独的 getDetail 请求去拿数据,这个应该算基础原则了,提一个点:

/// V1
class Acontroller: UIViewController {
    var uid: Int64?
    var userName: String?
}

/// V2
class Bcontroller: UIViewController {

    enum `Type` {
        case userLogin
        case userDetail(uid: Int64?, userName: String?)
    }
    var type: Type
}

/// V3
class Ccontroller: UIViewController {

    enum `Type` {
        case userLogin
        case userDetail
    }
    struct Params {
        let type: Ccontroller.Type
        var uid: Int64?
        var userName: String?
    }
    var param: Params?
}

V2 版本常见于一个 vc 处理多种场景类似的业务,定义枚举指代业务入口,枚举数据绑定来传参,但有一个问题是单个 case 绑定的数据也会出现膨胀。当然也可以写成case userDetail(data: Params?)这样来处理;还一个问题是项目里 if case .userDetail = self.type 之类的代码会变多,没法利用 switch-case 块来保证不可变;所以如果参数可能会超过2个,我会采用类似 V3 的写法,如有必要还可以向 Params 类添加方法来满足需要。

一些基本规则:

/// 单纯讲传参
class Controller: UIViewController {
    /// private >> public
    private var param: Params?
    /// init >> set
    required init(param: Params?) { ... }

    /// 确实有外部修改必要的,func >> var
    func updateProcessData(with userName: String?) {
        self.param.userName = userName
    }
}

/// 嵌套类定义过长可以用 extension 分开,或者不用嵌套类
extension Controller {
    enum `Type` {
        case userLogin
        case userDetail
    }
    struct Params {
        let type: Ccontroller.Type
        var uid: Int64?
        var userName: String?
    }
}

嵌套类有一个坑点,如果嵌套类继承自NSObject,必须用@objc标签重命名一个永远不会冲突的名字,否则打包的时候会因类名相同报错。

2018-12-16:原文

将DataSource作为一个单独的类分离出来

原文使用UITableViewDataSource做示例,并指出这种方法可以推及到像UICollectionViewDataSource等其他Protocols上,要理解这一点其实只要再仔细想一下Delegate的模式就可以明白——既然ViewController作为一个类可以实现委托方法,那么在ViewController过于臃肿的情况下,我们自然可以再让别的类来实现这些委托方法。

但要将这一思路应用在实际项目的重构上,相较于原作者DEMO的形式我认为还有几点需要更细致地说明。我们首先从最简单的形式开始,先实现一个最简单的TableView结构:

@interface LOStyle_1_View()<UITableViewDelegate>

@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) NSArray *cellDataArray;

@end

@implementation LOStyle_1_View

#pragma mark Getter

- (UITableView *)tableView
{
    if (!_tableView) {
        _tableView = [[UITableView alloc]init];
        _tableView.delegate = self;
        _tableView.dataSource = self.style_1_dataSource;
        _tableView.tableFooterView = [[UIView alloc]initWithFrame:CGRectZero];
        _tableView.separatorStyle = UITableViewCellSeparatorStyleNone;

        [_tableView registerClass:[LOStyle_1_Cell class] forCellReuseIdentifier:NSStringFromClass([LOStyle_1_Cell class])];
    }
    return _tableView;
}

- (NSArray *)cellDataArray
{
    if (!_cellDataArray) {
        _cellDataArray = @[[UIColor greenColor]];
    }
    return _cellDataArray;
}

#pragma mark TableViewDataSource

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return self.cellDataArray.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    LOStyle_1_Cell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass([LOStyle_1_Cell class])];
    cell.backgroundColor = self.cellDataArray[indexPath.row];
    return cell;
}

现在这个是最简单的例子,没有Model,没有网络请求和回调,也没有数据绑定;然后我们假装业务庞杂,设置cell属性的代码开始变多:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    LOStyle_1_Cell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass([LOStyle_1_Cell class])];
    cell.backgroundColor = self.cellDataArray[indexPath.row];
    cell.textLabel.text = @"test...";
    //do sth...
    //...
    return cell;
}

当然我们可以使用Model,将具体的属性赋值操作转到cell内部来解决:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    LOStyle_1_Cell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass([LOStyle_1_Cell class])];
    cell.cellInfo = self.cellDataArray[indexPath.row];
    return cell;
}

但实际的代码可能还是看着庞杂,我们就先直接简单地新建一个类,把DataSource方法都移过去,这时候的代码可能类似这样:

@implementation LOStyle_1_View
- (void)initView {
    self.style_1_dataSource.cellDataArray = self.cellDataArray;
    self.tableView.dataSource = self.style_1_dataSource;
}
@end

@interface LOStyle_1_DataSource : NSObject<UITableViewDataSource>
@property (nonatomic, strong) NSArray<LOStyle_1_CellModel *> *cellDataArray;
@end

@implementation LOStyle_1_DataSource
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    LOStyle_1_Cell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass([LOStyle_1_Cell class])];
    cell.cellInfo = self.cellDataArray[indexPath.row];  //Tag_1
    return cell;
}
@end

注意这里和原作者DEMO有所不同的是,原作者DEMO将Tag_1处的赋值代码用Block或代理又交回给了持有TableViewLOStyle_1_View;就目前的代码而言,我们好像觉得那样做还没什么必要。接下来,我们在LOStyle_1_View里放置一个按钮假装成网络请求的回调,按一下按钮,self.cellDataArray的值就会改变;这下我们必须保证LOStyle_1_DataSource持有的cellDataArray值先更新,然后才能去刷新TableView,代码可能会变成这样:

- (void)dataButtonEvent {
    //get new data...
    self.cellDataArray = [newDataArray copy];
    self.style_1_dataSource.cellDataArray = self.cellDataArray;
    [self.tableView reloadData];
}

这个时候我们应当注意到事情有些麻烦了:如果style_1_dataSource有多个属性,或者有多个网络请求等等多个落点的情况,写self.style_1_dataSource的地方就越来越多,可能还需要注意先后顺序,那再这样写肯定是难以接受的,怎么办?在目前的情况下,data和view都是由LOStyle_1_View来持有,那么我们可以仿照数据绑定的思路,请出一个dataHelper类来帮忙:dataHelper类管理所有数据并由LOStyle_1_View持有,这样style_1_dataSource只要从一开始取一下dataHelper就好,来看一下具体实现:

@interface LOStyle_1_DataHelper : NSObject
@property (nonatomic, strong) NSArray<LOStyle_1_CellModel *> *cellDataArray;
@end

@interface LOStyle_1_DataSource ()
@property (nonatomic, weak) LOStyle_1_DataHelper *dataHelper;  //Tag_2
@end

@implementation LOStyle_1_DataSource
//Tag_3
- (instancetype)initWithDataHelper:(LOStyle_1_DataHelper *)dataHelper
{
    if (self = [super init]) {
        self.dataHelper = dataHelper;
    }
    return self;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return self.dataHelper.cellDataArray.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath 
{
    LOStyle_1_Cell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass([LOStyle_1_Cell class])];
    cell.cellInfo = self.dataHelper.cellDataArray[indexPath.row];
    return cell;
}
@end

@interface LOStyle_1_View()<UITableViewDelegate>
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) LOStyle_1_DataSource *style_1_dataSource;
@property (nonatomic, strong) LOStyle_1_DataHelper *cellDataHelper;
@end

@implementation LOStyle_1_View

- (void)moreDataButtonEvent
{
    //get more data...
    self.cellDataHelper.cellDataArray = [tempArray copy];
    [self.tableView reloadData];
}
@end

注意这里Tag_2用了弱引用,因为dataHelperLOStyle_1_View维护,数据更新时dataHelper的值自然会随之改变,DataSource类撒手不管了;由于只需在初始化的时候取一次值,我们遵循设计规范在Tag_3处给出自定义的初始化方法,对外不暴露属性。现在dataHelper甚至可以拿到cell实例,和cell相关的一些操作甚至都可以扔给dataHelper去做来进一步拆分,当然这都是后话,我们可以灵活选择使用。回到上面Tag_1处给cell赋值的操作,这里进一步的优化内容在issue1-2,现在暂先不讨论;随着业务变化,极有可能出现多个不同样式的cell,我们回头看一下原作者datasource的初始化示例代码:

self.photosArrayDataSource = [[ArrayDataSource alloc] initWithItems:photos
                                  cellIdentifier:PhotoCellIdentifier
                                  configureCellBlock:configureCell];
self.tableView.dataSource = self.photosArrayDataSource;

可以看出他在设计上是偏向于想要将整个ArrayDataSource复用的,但我觉得一般DataSource类如果撇去和cell相关的操作其实本身代码量并不多,由于我们是先对ViewController瘦身,可以先把整个Protocols拆到DataSource类里,如果这样还是庞杂那再由Helper来完成拆分和转交,不用再递回上层去;当然实践当中这部分就非常灵活了,博主也只是举个例子讲解一下思路,我的DEMO代码放在GitHub上,写文章的时候回去看了一下自己的项目,我自己是利用这个思路来处理CollectionView的代理方法,同时发现如果大家想看更复杂一点的例子,可以去看一下美洽客服(不是广告!)聊天页面cell的实现,他们是在Helper类里面用代理再拆分,cell则利用继承来在具体的子cell内部更新数据,希望对大家有所帮助。

将业务逻辑、网络请求移到 Model 层中,必要时创建 Store 类

这里多是原作者的一些经验之谈,我将它们合并成一句话,其实这里涉及到旷古已久的胖Model瘦Model之争,包括之前流行的MVVM思想也有相似之处,我个人认为这些其实都是对MVC的补充,iOSC似乎更容易变得臃肿庞大,那么大家就来对它来进行拆分,大体上都是按照功能逻辑来进行腾挪,但具体的界限各人都有自己的心得经验,我是觉得即使是同一个人面对两套不同的业务,也没有一成之法去说一定要怎样怎样划分,设计模式本就是为了应对业务和需求的不断变化而生的,不要被自己定的规矩套死。当然这里针对作者的例子我也想说一下我的经验,同样是User类,我们假设User有一个time属性,这种情况下我们从服务器接收到的数据一般是不能直接展示的,有不少常见的写法:

  • 当场直接写:
  //do some format...
  NSString *formatTimeString = [NSString stringWithFormat:@"format=%@", self.time];
  view.timeLabel.text = formatTimeString;
  • 当前模块刚好有工具类:
  #import "HomeViewHelper.h"
  HomeViewHelper *helper = [[HomeViewHelper alloc]initWithUser:self.user];
  view.timeLabel.text = [helper userFormatTime];
  • 工厂模式,代码会像是:
  #import "LOIFactory.h"
  view.timeLabel.text = [LOIFactory formatTime:self.user.time];
  • 升级一下,像原作者一样用扩展来做:
  #import "User+Helper.h"
  view.timeLabel.text = [self.user formatTime];
  • 增加属性,甚至直接重写Getter:
  @interface User : NSObject
  @property (nonatomic, assign) NSInteger time;
  @property (nonatomic, strong) NSString *formatTime;
  @end

  @implementation User
  - (NSString *)formatTime {
      return [NSString stringWithFormat:@"format=%@", self.time];  //do some format...
  }
  • 连根拔起:
  @interface User : NSObject
  @property (nonatomic, strong) NSString *time;
  @end

  @implementation DataManager
  - (void)loadUserData {
      self.user.time = [NSString stringWithFormat:@"format=%@", self.userData.time];
  }

列了这么多写法,我想说的其实是相对于这些写法之间的差异,User类的性质有的时候更能决定它们孰优孰劣,甚至工具类本身的影响都更大:User+Helper.h还有什么功能?哪些地方会用到?今后会不会因为User+Helper.h其他功能的改动而不得不拆分time属性?这个格式转换功能其他类的属性会不会用到?这是一个非常简单的例子,但前面提出的一些问题其实涉及到APP整体的一些思考,我觉得有时候多去从全局的角度考虑一下,决定time属性的具体写法或许会更加容易。

将DataSource作为一个单独的类分离出来

我的个人习惯是所有的ViewController都有一个View与之对应,所有子控件都在这个View上布局,利用Xcode自带的代码片段功能实现起来会更轻松一些,在前面的示例代码中也可以看出我的这一习惯。我认为在UI层要小心的是复用的程度,清晰的模块划分有时候比复用几个小控件更重要。

注意ViewController与其他对象的通讯

诚如作者所言,这是一个复杂的主题,我也简单地介绍一下我日常使用的一些准则:

  • 信息尽量通过属性单向链式传递,传递时涉及的属性越少越好,举个例子:
  VC_A.user = self.user;
  VC_B.uid = self.uid;

如果情况允许我肯定是倾向于使用VC_B的传值方式;即使user可能是单例,如果VC_BVC_A之间有逻辑关系,那还是让VC_A用传值的方式递交过来更好。

  • 杜绝空值传递,合法性判断越早做越好。

总结

本篇文章多数是经验之谈,作者讲了很多实用的技巧,但经验类的知识要想真正地消化吸收为我所用,还是需要真正地通过项目和需求实在碰撞过后才会有自己的体悟,这也是我的第一篇长笔记,凛冽寒冬,一提笔竟然写了六个多小时,也衷心祝福有缘看到本文的朋友,努力共勉!