阅读视图

发现新文章,点击刷新页面。

Casa的PThread多线程课程




我平时写代码不怎么用到多线程,为什么还要学这个课程?


其实日常工作中使用多线程的场景还是很多的。

只不过是需要亲自写多线程完成的任务也都很简单,开个线程做事情就结束了。若是要完成更复杂一点的任务的话,那就找第三方库去完成了。


在这种情况下,一旦有什么bug,因为你对多线程不那么熟悉,就不会第一个考虑有可能是多线程导致的bug。但事实上90%的疑难bug,都是多线程导致的。


另外就是,现在面试的时候,多线程基本上是面试官的必问题,而且往往也会是整场面试里最难的那部分。

这种题如果能答上来,会加分很多。

学完这门课程之后,你至少是达到熟悉多线程的水平了,应对面试绰绰有余。




我用的是Java / Golang / Swift / Python... 即使是写多线程,也基本上不会用PThread去写,我学PThread有什么用?


确实市面上有各种多线程的库,各种语言对多线程也有各自的封装。

但是,市面上所有的库所有的封装,归根结底,他们全部都是基于PThread的,无一例外。


如果你学会了PThread,那么不管是什么库什么语言,基本上拿来就用。

如果你不熟悉某个库,但你熟悉PThread,那么你就会知道PThread有的功能它一定有,你搜索的时候也就有了清晰的方向。




网上多线程文章那么多,我随便找一下一大把。我自己多花点时间去学习就好了。


是的,完全靠自学的话搞定多线程PThread也是没问题的,只是会多花点时间。

因为多线程的复杂度在这里,要找到一个比较好的资料其实不那么容易的。


这门课程对PThread多线程的讲解比较全面,在准备的过程中我也花了不少时间对资料去做收集和甄别。

包括但不限于:线程和线程的各种属性、线程的退出/取消/结束、线程数据的获取(pthread_join)、锁和各种锁,各种属性、TSD(Thread-Specific-Data)、条件变量(Condition Variable)、信号量(Semaphore)、栅栏(pthread_barrier)、优先级反转(Priority Ceiling/Priority Inheritance)、异常结束处理机制(Robust)等等。


所以对你而言,就省去了很多收集资料的时间和甄别资料的精力。




工作中也会用些多线程的技术,虽然再深一点的知识就可能不是很了解,但是以目前的知识来看,我搞定日常工作没什么问题的。


在多线程体系下面,解决一个问题其实有很多种方案的。

就比如semaphore和barrier即使没有,也是可以用锁或者条件变量去完成的。

但是如果知道semaphore怎么用,barrier怎么用,是可以做出更优雅的方案的。


另外,多线程体系下面也是有很多坑的。

更坑的点在于,由于多线程环境是不稳定的,所以出问题绝大多数都是偶发的。


如果了解更深的内容,一方面解决问题的时候就能够绕过这些坑,另一方面解决偶发问题的时候就会更有思路。




这门PThread课程要学多久?我可能没有那么多时间。


PThread的难点其实不在于技术本身难,而是在于多线程的环境复杂,有很多奇妙的问题如果不知道来龙去脉,就很难理解。

所以我会用最简单的方式去把复杂问题讲出来,是十分易于理解的。


这个课程一共20节,每节短的1分多钟,长的十几分钟,总共两个小时。

基本上就是你先找个地方坐下来,2小时之后再站起来,你就已经一技傍身了。




299元的课程我能收获什么?


1. 全面且系统完整的多线程知识


多线程其实不难,但是很多资料不是直接基于PThread去做的。由于不同库不同语言有不同的实现,就导致你学了一门,换个语言换个实现还得重新再学。


但所有实现都是基于PThread的,所以学多线程应该从PThread开始学起。

全面且系统完整的PThread资料在网上其实不太多,我在做这门课程的时候把PThread所有的功能函数(非功能函数就是一些属性配置函数,排除掉这些就是功能函数)都过了一遍,是比较全面完整的。



2. 能让你2小时快速掌握多线程


我见过好多资料,动辄10多个小时,时间实在是太长。

我觉得学习最要紧的是迅速建立正反馈,这样你才能不断不断地坚持下去。

这门2小时的课程帮你快速构建一个全面完整的基础,使得你的学习过程非常快速,正反馈频次高。

学完这门课以后,再去看别的多线程资料,基本上就没有什么问题了,在未来的过程中,也能持续给到正反馈。



3. 面试的时候不再紧张多线程


一般来说,高级一点的面试题一定是会有多线程的。

而且面试题在一定程度上会脱离实际,因为面试题更多是针对理论能力的考察,并非是完全针对实现的考察。

就像物理题一样,总是会做一些“不切实际的假设”,例如光滑的平面,刚性的小球等。

而多线程题目特别适合来做理论的考察,因为能被用来问的场景特别多。学完这门课程,你就建立了一个系统完整的多线程知识理论框架。

在回答面试题的时候,通过已经掌握的知识理论,就能够推导出面试场景下的多线程方案,面试基本就没什么问题了。

另外,在准备面试的时候,迅速掌握是比较重要的事情。2h的课程正如前面所说:

基本上就是你先找个地方坐下来,2小时之后再站起来,你就已经一技傍身了。




CTMediator的Swift应用




如果你的工程是采用CTMediator方案做的组件化,看完本文以后,你就可以做到渐进式地迁移到Swift了。 CTMediator支持所有情况的调用,具体可以看文后总结。你的工程可以让Swift组件和Objective-C组件通过CTMediator混合调用 也就是说:以后再开新的组件,可以直接用Swift来写,旧有代码不会收到任何影响。




这篇文章适用最低支持iOS 8的工程,且必须使用Cocoapods 1.6.0.beta.1以上的版本




本文提及的框架:CTMediator


相关文章:《iOS应用架构谈 组件化方案》 《在现有工程中实施基于CTMediator的组件化方案


Swift调度Demo工程:SwfitDemo,Objective-C调度Demo工程:ModulizedMainProject


跑Demo前先添加私有仓库:


pod repo add PrivatePods https://github.com/ModulizationDemo/PrivatePods.git


然后进入对应工程pod update --verbose即可。




最近我惊喜地发现,几天前(8月17号)Cocoapods在1.6.0.beta.1版提供了--use-modular-headers参数,它可以大大简化依赖Objective-C Pod的Swift Pod的发版流程。


于是我打算把我过去这些为Objective-C的工程写的框架全部用Swift再写一遍。目前已经完成的只有SwiftHandyFrame。(这个工具在我自己的业余项目里用了一圈,感觉很不错,达到了我之前设想的目的。)


然而以上开源工程中,有一个从设计思想上就非常依赖Objective-C的框架是CTMediator


我尝试去思考一个新的、符合Swift且优雅的组件化方案,但并没有找到比CTMediator更好或一样好的方案。于是我探索尝试了一下,也填了一些坑。最终我发现CTMediator方案本身虽然很不Swift,但即使是在Swift工程中,CTMediator依旧是一个很优雅的组件化方案。因为:


  1. CTMediator是一套不需要随工程迭代修改的代码,它也不与任何业务产生交互,引入之后对Swift工程开发的影响为0
  2. Swift工程可以使用Extension替代原先Objective-C工程的Category并能够正常发版。因此在方案实施全程都可以做到纯Swift编码,可以完全忘记CTMediator是Objective-C工程这回事儿
  3. 新版的CocoaPods对依赖Objective-C Pod的Swift Pod十分友好,在Swift Pod中引入CTMediator可以做到完全无感,你即使不会写Objective-C,也不影响你来使用CTMediator方案
  4. Swift工程也可以使用Objective-C工程历史遗留的Category和Target-Action来完成调度,因此Objective-C开发团队可以使用旧有代码渐进地实施Swift迁移,而不必担心引入新的bug。



综合以上几点,可以给到2个结论:


  1. CTMediator方案是可以优雅地应用在Swift工程中的
  2. 凡是过去应用CTMediator组件化方案的Objective-C团队都可以做到无痛迁移Swift



前面的部分论证了结论1


文章接下来的内容有两个目的:


  1. 说明结论2。因此之前就采用CTMediator组件化方案的Objective-C团队们,你们看完这篇文章之后,就可以放心地让团队往Swift方向迁移了。
  2. 一直在做Swift开发的团队们,你们也可以放心使用CTMediator方案了。它不仅更加强大,同时CTMediator是Objective-C工程这一点对Swift工程(无论是现有工程还是新工程)的影响为0




  1. 为Swift响应者组件提供Target-Action
  2. 为方便调度给CTMediator写Extension
  3. Swift响应者工程、Swift调度者工程、Extension工程的发版
  4. Swift调用者通过Extension调度Objective-C响应者
  5. 总结: Swift组件与Objective-C组件互相调用的全部8种情况,及其对策









1. 为Swift响应者组件提供Target-Action



Target-Action 的目的



CTMediator方案的表象是通过runtime调度Target-Action,但是CTMediator方案的本质是在不需要动业务代码的情况下,完成调度


所以在提供Target-Action的时候,我们一般都选择让Action把对应的业务做完,如果有调用者需要补充逻辑的,通过closure给到。而不是返回一个什么对象给调用者,然后调用者再去做逻辑。


举个例子:


A需要展示B页面。你可以选择:

1. 让B的Target-Action直接返回一个UIViewController,然后A去push或者present。
2. 让B的Target-Action直接完成页面展示的操作,具体是push还是present,由A传过来的参数决定。

一个好的Target-Action倾向于2。因为这种做法对于A业务来说,留下的足迹更小,踏雪无痕是我们追求的目标。




Swift工程声明Target-Action的注意事项



  1. Target对象必须要继承自NSObject
  2. Action方法必须带@objc前缀
  3. Action方法第一个参数不能有Argument Label



一个Swift版的Target-Action如下:


class Target_A: NSObject { // 必须要继承自NSObject

    // 正确的Action声明
    @objc func Action_viewController(_ params:[AnyHashable:Any]?) -> UIViewController {

    }

    // 错误的Action声明:没有带@objc前缀
    func Action_viewController(_ params:[AnyHashable:Any]?) -> UIViewController {

    }

    // 错误的Action声明:方法带上了Argument Label
    func Action_viewController(viewControllerParams params:[AnyHashable:Any]?) -> UIViewController {

    }

    // 错误的Action声明:方法带上了Argument Label
    func Action_viewController(params:[AnyHashable:Any]?) -> UIViewController {

    }

}


params 的类型也可以为NSDictionary,所以这么写也是可以的:


    func Action_viewController(params:NSDictionary) -> UIViewController {

    }




Swift工程实现Action时的注意事项



这里要注意的点在于如何处理block或closure。主要原因是如果调用者通过Category来发起调用,那么就只能传递block。如果是通过Extension来发起调用,那么就只能传递closure。至于调用者是Swift还是Objective-C倒是无所谓的。


  • 我们先看调用者是通过Category发起的调用,然后响应者是Swift的情况


由于Category中只能传递block,所以此时你的Action获得了一个block。在swift响应者中,这个Action就要这么写:


    @objc func Action_viewController(_ params:[AnyHashable:Any]?) -> UIViewController {

        if let actionParams = params {
            let block = actionParams["callback"]

            // 转换一下
            typealias CallbackType = @convention(block) (String) -> Void
            let blockPtr = UnsafeRawPointer(Unmanaged<AnyObject>.passUnretained(block as AnyObject).toOpaque())
            let callback = unsafeBitCast(blockPtr, to: CallbackType.self)

            // 此时block就变成了closure,就可以正常调用了
            callback("success") 
        }

        let aViewController = ViewController()
        return aViewController
    }




  • 我们再看调用者通过Extension发起的调用,然后响应者是Swift的情况


这种情况就很自然了,因为两边都是Swift环境(对的,我们完全可以忽略CTMediator是一个Objective-C组件的事实),所以可以直接使用:


    @objc func Action_viewController(_ params:[AnyHashable:Any]?) -> UIViewController {

        if let actionParams = params {
            if let callback = actionParams["callback"] as? (String) -> Void {
                callback("success")
            }
        }

        let aViewController = ViewController()
        return aViewController

    }


所以如果你的响应者需要同时服务来自Category的调度和Extension的调度,而且需要处理block或closure的话,你就需要在Category中或Extension中给到一个参数,来决定你如何实现这个Action。不过一般来说这种情况很少,为了同一个调度又写Category又写Extension基本上是不太可能的。







2. 为方便调度给CTMediator写Extension



Extension 的目的



理论上我们可以直接使用CTMediator来完成调度。但是这么做的话,写调用代码的工程师就会产生这样的迷惑:“为了调度成功,我应该给到什么target,什么action,以及参数都要传哪些?”


// 如果直接这么使用CTMediator,调用工程师需要去查文档获知自己这次调用对应的target-action是什么,参数是什么,module_name是什么。
    CTMediator.sharedInstance.performTarget("A",action: "viewController", params: ["name":"casa", "age":18, kCTMediatorParamsKeySwiftTargetModuleName:"module_name"], shouldCacheTarget: false)


所以为了不让写代码的工程师迷惑,我们提供Extension来描述一个调用应该传什么样的参数。在Extension的实现中,我们写入Target、Action以及ModuleName,这样工程师就不必迷惑了。


// extension CTMediator
    public func A_show(callback:@escaping (String) -> Void) -> UIViewController?

// 给CTMediator写了extension之后,调用工程师拿到方法,按照方法的参数列表给到参数就可以了,不必去考虑对应的target-action是什么、参数是什么、module_name是什么了。
    let acontroller = CTMediator.sharedInstance().A_show { (result) in
        print(result)
    }


写Extension的注意事项



  1. 写Extension的人需要知道响应者的module名,并且在传入的参数字典中给到,例如:[kCTMediatorParamsKeySwiftTargetModuleName:"module_name"],这是Swift与Objective-C不同的地方。如果响应者是Swift,不给到Module名的话,runtime是调度不到响应者target-action的。如果是Objective-C的响应者,这个就可以省略了。
  2. cocoapods发版的时候要带--use-modular-headers,因为这个组件依赖了CTMediator,它是Objective-C的工程。--use-modular-headers这个参数在Cocoapods 1.6.0.beta.1以上的版本才有。
  3. 如果Extension里的某个方法要被Objective-C使用,那需要带上前缀@objc



一个完整可行的Extension是这样的:


import CTMediator

extension CTMediator {
    // 如果这个方法也要给Objective-C工程调用,就需要加上@objc
    @objc public func A_show(callback:@escaping (String) -> Void) -> UIViewController? {
        let params = [
            "callback":callback,
            kCTMediatorParamsKeySwiftTargetModuleName:"A_swift" // 需要给到module名
            ] as [AnyHashable : Any]
        if let viewController = self.performTarget("A", action: "viewController", params: params, shouldCacheTarget: false) as? UIViewController {
            return viewController
        }
        return nil
    }
}


它的发版命令是这样的:


// 带上--use-modular-headers,Cocoapods 1.6.0.beta.1以上支持
pod repo push Your_Repository Your_Podspec_file.podspec --verbose --allow-warnings --use-libraries --use-modular-headers







3. Swift响应者工程、Swift调度者工程、Extension工程的发版



其实跟之前私有pod的发版流程一模一样,只是如果你的Swift工程依赖了Objective-C工程的话,你多带一个--use-modular-headers参数而已。







4. Swift调用者通过Extension调度Objective-C响应者



这种情况下要注意的其实还是只有block和closure之间的转化,Extension需要将Swift的Closure转化成Objective-C的block对象之后再传递,完整的Extension示例如下:


extension CTMediator {
    public func A_showObjc(callback:@escaping (String) -> Void) -> UIViewController? {

        // 将closure类型转化为block类型
        let callbackBlock = callback as @convention(block) (String) -> Void
        let callbackBlockObject = unsafeBitCast(callbackBlock, to: AnyObject.self)

        // 转化完毕就可以放入params中传递了
        let params = ["callback":callbackBlockObject] as [AnyHashable:Any]

        if let viewController = self.performTarget("A", action: "viewController", params: params, shouldCacheTarget: false) as? UIViewController {
            return viewController
        }
        return nil
    }
}




对应的响应者Target-Action如下:


- (UIViewController *)Action_viewController:(NSDictionary *)params
{
    typedef void (^CallbackType)(NSString *);
    CallbackType callback = params[@"callback"];
    if (callback) {
        callback(@"success");
    }
    AViewController *viewController = [[AViewController alloc] init];
    return viewController;
}


这样就能保证调用成功。







5. 总结: Swift组件与Objective-C组件互相调用的全部8种情况,及其对策



其实如果你的工程是Swift调用者、Swift响应者的情况,那么应用CTMediator就一点问题都没有。


对于Objective-C的开发团队来说,如果开始渐进地将工程Swift化,那么就需要分各种情况去处理block和closure转化的问题。在这里我把可能出现的所有情况及其处理方法总结如下:




1 Swift调用者 + Extension + Swift响应者



  1. Extension中给到的params需要带有kCTMediatorParamsKeySwiftTargetModuleNamekey来给到target所在module的名字
  2. Extension中不需要针对closure做任何转化
  3. 响应者target-action跟正常情况一样写
  4. 响应者action方法首参数不要带Argument Label,用_




2 Swift调用者 + Extension + Objective-C响应者



  1. Extension中给到的params不需要带kCTMediatorParamsKeySwiftTargetModuleNamekey
  2. Extension中需要将closure转化成block对象之后再放入params传过去
  3. 响应者的Target-Action跟正常情况一样写




3 Swift调用者 + Category + Swift响应者



  1. Category中给到的params需要带有kCTMediatorParamsKeySwiftTargetModuleNamekey来给到target所在module的名字
  2. Category中不需要针对block做任何转化
  3. 响应者的Target-Action需要将block转化成closure
  4. 响应者action方法首参数不要带Argument Label,用_




4 Swift调用者 + Category + Objective-C响应者



  1. Category中给到的params不需要带kCTMediatorParamsKeySwiftTargetModuleNamekey
  2. Category中不需要针对block做任何转化
  3. 响应者的Target-Action跟正常情况一样写




5 Objective-C调用者 + Category + Objective-C响应者



  1. Category中给到的params不需要带kCTMediatorParamsKeySwiftTargetModuleNamekey
  2. Category中不需要针对block做任何转化
  3. 响应者的Target-Action跟正常情况一样写




6 Objective-C调用者 + Category + Swift响应者



  1. Category中给到的params需要带有kCTMediatorParamsKeySwiftTargetModuleNamekey来给到target所在module的名字
  2. Category中不需要针对block做任何转化
  3. 响应者的Target-Action需要将block转化成closure
  4. 响应者action方法首参数不要带Argument Label,用_




7 Objective-C调用者 + Extension + Objective-C响应者



  1. Extension中给到的params不需要带kCTMediatorParamsKeySwiftTargetModuleNamekey
  2. Extension中需要将closure转化成block对象之后再放入params传过去
  3. Extension中的方法需要带前缀@objc
  4. 响应者的Target-Action跟正常情况一样写




8 Objective-C调用者 + Extension + Swift响应者



  1. Extension中给到的params需要带有kCTMediatorParamsKeySwiftTargetModuleNamekey来给到target所在module的名字
  2. Extension中不需要针对closure做任何转化
  3. Extension中的方法需要带前缀@objc
  4. 响应者target-action跟正常情况一样写
  5. 响应者action方法首参数不要带Argument Label,用_




最后给到示例工程:


Objective-C为调用者:ModulizedMainProject


Swift为调用者:SwfitDemo


使用前先添加私有仓库:

pod repo add PrivatePods https://github.com/ModulizationDemo/PrivatePods.git


然后进入对应工程pod update --verbose即可。




关于CTMediator还有什么内容我没讲,或者没讲清楚的,再或者你针对CTMediator方案有任何问题的,都可以在文章下方评论区向我提问。

iOS11中网络层的一些变化(Session707&709脱水版)




已授权发表至《iOS成长之路3期·WWDC17内参》


本文将介绍 iOS11 下网络层(NSURLSession)的一些变化。这篇文章分4部分来总结了Session 707和709:

  1. iOS11中网络层新出的功能
  2. iOS11中网络层优化的功能
  3. iOS11中网络层的最佳实践
  4. 苹果对网络层未来的规划


因为这一次很多内容其实是苹果用新的技术来给网络层做的优化,所以新功能优化很难界定。我这边采用的判断标准是:


  1. 如果是需要工程师代码配合的优化,就算是新功能
  2. 如果是不需要工程师代码配合的,哪怕是应用了新技术,我也把它归类为优化




1. iOS11中网络层新出的功能



1.1 Network Extension Framework有新API


提供了两个新类:NEHotSpotConfiguration、NEDNSProxyProvider。


  • NEHotSpotConfiguration

NEHotSpotConfiguration可以让你的智能设备在链接手机App之后,能够很方便地通过在手机App上的操作来实现热点的链接。

例如你买了一个网络摄像头,你想要连上摄像头的Wi-Fi热点去配置这个摄像头的话,以前要这么操作:

图1

现在用NEHotSpotConfiguration就能很方便地搞定事情了:

图2

当然,这套API也可以被拿来模拟各种网络环境,在测试App的时候很有用。


  • NEDNSProxyProvider

NEDNSProxyProvider可以用来设置你的手机如何跟DNS做交互。你可以自己发DNS请求,也可以自己基于不同的协议去做DNS查询。例如DNS over TLS,DNS over HTTP。




1.2 可以进行多路多协议的网络操作(Multipath Protocols for Mobile Devices)



在之前的iOS版本中,网络如果要从Wi-Fi模式变成Cellular(2/3/4G)模式,那就是先断掉Wi-Fi然后再连上Cellular,然后数据包就从原来的Wi-Fi链路迁移到Cellular链路去发送。


在链路切换过程中链接肯定就断掉了,为了解决这个问题,苹果爸爸搞了Multipath Protocols for Mobile Devices(移动设备多路协议),这使得移动设备的TCP包可以在这两个(多个)链路上随意切换着发(同时开启两个流量链路),而不必断线重连。


效果就是:Wi-Fi和Cellular可以共存,相互辅助。有三个模式,前两个可选,第三个只能自己私底下玩:



  • Handover Mode(高可靠模式)


这种模式下优先考虑的是链接的可靠性。只有在Wi-Fi信号不好的时候,流量才会走Cellular。如果Wi-Fi信号好,但是Wi-Fi很慢,这时候也不会切到Cellular链路。


这种模式在beta版中已经支持了。



  • Interactive Mode(低延时模式)


这种模式下优先考虑的是链接的低延时。系统会看Wi-Fi快还是Cellular快。如果Cellular比Wi-Fi快,哪怕此时Wi-Fi信号很好,系统也会把流量切到Cellular链路。


这种模式在未来的beta版会支持。



  • Aggregation Mode(混合模式)


在这种模式下,Wi-Fi和Cellular会同时起作用。如果Wi-Fi是1G带宽,Cellular也是1G带宽,那么你的设备就能享受2G带宽。


嗯,很好很强大。但你只能拿来玩,不能生产环境中使用。


因为苹果爸爸不想让你用。其实他说的理由是希望开发者自己好好考虑1和2用哪个,因为如果3也能用的话,苹果爸爸知道你们就完全不会考虑1和2了,直接用3了。反正用户流量不是开发者掏钱。


这个模式只能够在系统设置里面自己开启来玩,不能像1和2那样可以让开发者在应用中通过NSURLSessionConfiguration.multipathServiceType自己选。



需要注意的是,Multipath Protocols for Mobile Devices这个功能同时也需要服务端支持MPTCP(Multipath TCP)才行,如果服务端不支持的话,光客户端支持没用。linux起了一个项目在做这个事情,项目地址:https://multipath-tcp.org。有兴趣的同学可以自己去看一下。




1.3 ProgressReporting协议



iOS11提供了ProgressReporting协议,并且NSURLSessionTask实现了这个协议,让你能够获得progress对象,这个progress对象可以以0~1.0的方式告诉你当前进度,而不用你自己去拿到已获得的数据量去除以需要获得的数据总量从而得出进度。因为有的时候你并不一定能够拿到数据总量。然后这个progress对象跟NSURLSessionTask的绑定是双向的:你调用progress对象的cancel、pause、resume也会使得task变为cancel、pause、resume,反之亦然。


图3



1.4 URLSessionStreamTask 和 authentication proxies


你需要使用URLSessionStreamTask去替代之前的NSInputStream/NSOutputStream,它支持:



  1. 使用host和port来进行TCP/IP连接
  2. 支持基于STARTTLS的安全握手协议
  3. 支持Navigation of authenticating HTTPS Proxies。事实上就是:如果你是通过proxy访问的网络,当proxy问你要证书的时候,iOS11会自动帮你从keychain里面找到证书给出去。




1.5 URLSession Adaptable Connectivity API



以前进行网络调用时,如果网络不通,那么系统就会报个错告诉你网络不通。这时候你要么轮询要么让用户手动retry,然后网络通了请求才能发送出去。


现在你可以这么做:如果请求发送的时候网络不通,那么这个请求就会等到网络通了的时候再发出去。于是你就不用轮询了,用户也不用手动retry了。


具体做法:把NSURLSessionConfiguration的waitsForConnectivity设置成YES,拿它去生成NSURLSession去使用就好了。




1.6 URLSessionTask Scheduling API


这其实算是一种优化,但还是需要工程师代码配合的,它主要解决了三个问题:


  1. 没必要因为创建NSURLSessionTask而进行额外的后台加载
  2. 当后台请求创建但还没发出去的时候,这个被创建的请求有可能因为上下文变化的原因导致这个请求无意义
  3. iOS系统并不知道什么时候去发起你的请求才是最合适的



原先你要做后台数据加载的时候,流程是这样的:

图4

现在iOS11里,苹果把头两个步骤合并为一个步骤了:

图5


所以当应用在前台的时候,你就可以创建这个NSURLSessionTask,iOS11里面就不会在后台额外launch一次去创建NSURLSessionTask。这就解决了问题1。


iOS11在NSURLSessionTask的delegate里面提供了一个新的方法:urlSession:task:willBeginDelayedRequest:completionHandler:。系统在发起请求之前会调一个这个回调,然后在这个completionHandler里面你告诉系统这个请求是否要发出去,是否要修改。从而解决了问题2。


iOS11也给了NSURLSessionTask一个property:earliestBeginDate。系统在earlistBeginDate之前是不会发起这个请求的。你给这个task设置一个earliestBeginDate,就解决了问题3。


最后小胖子又补充了一下,你可以通过设置NSURLSessionTask的countOfBytesClientExpectsToSend和countOfBytesClientExpectsToReceive来让系统更好地调度你的后台网络任务。




2. iOS11中网络层优化的功能




2.1 Explicit Congestion Notification(ECN,显式拥塞通知)




先说一下ECN的好处:


  1. 可以最大化地使用网络带宽
  2. 减少包重发的次数,从而降低延迟,可以提高用户体验


然后说说ECN到底是什么:


显式那就有隐式。在以前,TCP/IP发现拥塞的方法是看有没有丢包情况,如果有,那它就认为当前网络有拥塞。于是TCP/IP就会降低发包速率,避免拥塞。这个过程我们可以理解为隐式拥塞通知显式拥塞通知就是TCP/IP会收到打上拥塞标记的数据包,TCP/IP发现这个拥塞标记的话,就认为网络出现了拥塞,从而降低数据吞吐量,最终避免恶化网络拥塞现象。


因为通过丢包来发现网络拥塞(隐式拥塞通知)是一件非常消耗成本的事情。接收方在发现丢包之后,需要重新要求发送方来发送之前丢的包,这就额外占用了资源。所以大家就想能不能不用通过丢包的方式来表达网络拥塞的状态,从而让发送端降低发包速率,缓解拥塞。


所以就有了显式拥塞通知。在支持ECN的链路上,如果出现拥塞现象的话,链路不会丢包,而是会在包的header里打上一个标记。接收方拿到这个带着标记的包之后,就认为网络有拥塞现象了,于是就可以降低发包速率,从而缓解网络拥塞。


最后,ECN是需要SQM算法(Smart Queue Management,智能队列管理)支持的。


这个功能在iOS10.3的时候就已经有了。当链路支持显式拥塞通知的时候,iOS10.3上会有50%的链接使用显式拥塞通知来表达网络链路的拥塞状态。从iOS11开始,在支持显式拥塞通知的的链路上,100%的链接都会使用这个技术来表达网络链路的拥塞状态。




2.2 iOS11里的网络操作被移动到User Space去了



原来网络操作都是内核去处理的,现在由每个App各自去处理了。


其实这一段我没有听太懂,说是这么做能够减少更多的上下文切换,从而匀出更多的时间让CPU去处理UI方面的事情,但我感觉好像本质上没什么变化?后来群里AloneMonkey同学说:user到内核会有软中断,涉及到user space的上写文参数到kernel space的映射拷贝,会有比较大的消耗,所以一般会尽量减少这种切换。因此事实上这种优化能够降低不必要的开销,从而提高应用执行的效率。


这么一来的话,以前使用Network Kernel Extension(OS X下)的同学,就不要用了,将来会被deprecate掉。换成Network Extension Framework就好。




2.3 Brotli Compression



iOS11支持Brotli压缩算法(RFC7932)。需要在HTTPS下才能使用,HTTP请求里的Header的Content-Encoding的值是br就表示使用了Brotli压缩算法。这套压缩算法相比gzip的压缩效率提高了15%。苹果浏览器Safari使用了NSURLSession,所以Safari也支持了Brotli。




2.4 Public Suffix List Updates



iOS11更新了Public Suffix List。


补充一下,Public Suffix List能够带来的主要好处有三个:避免超级cookie导致的隐私泄漏、让你的地址栏上的public suffix部分可以高亮、可以更好地对历史URL进行排序。所谓的Public Suffix就是域名的后半部分:.com .co .uk(不完全举例)这些。这个列表告诉了客户端(往往是各大厂商的浏览器)如何去区分域名的边界。


关于Public Suffix List Updates的具体介绍可以看https://publicsuffix.org




3. iOS11中网络层的最佳实践



3.1 IPv6



苹果爸爸在说:IPv6各种好,大家快来用。如果你不支持IPv6,爸爸就不让你上架。


要支持IPv6的话,老老实实用NSURLSession或者CFNetwork就OK了。


不要做的事情:


  1. 不用历史遗留的IPv4 API
  2. 不要直接用IPv4的地址做链接,应该用域名去做请求
  3. 发包前不要做各种检查:比如你在建立链接之前想看一下我当前这个设备是不是IPv4的地址,这种做法就不行
  4. 不要直接使用socket去发起请求



之前我们team也有App上架被拒,原因说的是IPv6不通过。但事实上如果你非常确认以上这些不要做的事情你没做,那么很有可能就是苹果审核人他网络不好,连不到你的服务器(现在还是有一大部分应用的审核在美国)。这类错误苹果都会在邮件中说你不支持IPv6,所以不要被它迷惑了。



3.2 不要引入其他的网络库,要使用苹果自己的API


苹果并不是在说AFNetworking、Alamofire不能用。这些第三方库本质上还是基于NSURLSession,也就是苹果的API去开发的。所以用它们没问题。


苹果的意思是不希望你使用别的基于Socket开发的网络库,例如:ACE、Asio这些。因为苹果的NSURLSession针对自家设备的特点,结合各种网络条件,针对电量、临时/后台请求等做了一系列优化。若是你不用NSURLSession去做网络请求,那这些优化就都没了,苹果后续新版本给到的新功能也会用不上了。




3.3 注意timeoutIntervalForResource和timeoutIntervalForRequest的区别



timeoutIntervalForResource是表示数据没有在指定的时间里面加载完,默认值是7天。


timeoutIntervalForRequest是表示在下载过程中,如果某段时间之内一直都没有接收到数据,那么就认为超时。


举个例子就是,如果你要下一个10G的数据,timeoutIntervalForResource设置成7天的话,你的网速特别慢:0.1k/s,7天都没下载完,那就超时了。虽然整个过程中,你一直在源源不断地下载。


如果你要下一个10G的数据,timeoutIntervalForRequest设置为20秒的话,下的过程中有超过20s的时间段并没有数据过来,那么这时候就也算超时。




3.4 一般来说一个App就一个NSURLSession就够了



以前迁移NSURLConnection到NSURLSession的时候,会有人每次都创建新的NSURLSession,但事实上这是没必要的。各个并行的NSURLSessionTask可以共享同一个NSURLSession。真正会使用多个NSURLSession的情况,老头就举了个例子:如果你用safari去开隐私模式的窗口访问网络,那么每个窗口就是一个新的NSURLSession,从而避免数据泄漏。


最后,如果你使用了多个NSURLSession的话,记得清理就好,不清理的话苹果是会产生内存泄漏的。




3.5 NSURLSession的delegate方法和快手block方法不要同时使用



如果你用了block,那么delegate就不会回调了。这事情仅有两个特例是两个都回调的:taskIsWaitingForConnectivity和didReceiveAuthenticateChallenge。




4. 苹果对网络层未来的规划



4.1 TLS1.3



苹果要把网络库整体迁移到支持TLS1.2,年底TLS1.3的标准应该能出来。现在基于TLS1.3草稿的实现可以弄下来自己测试着玩了。


最新的TLS1.3草稿已经出到21了:draft-ietf-tls-tls13-21


TLS1.3提供了加密的HTTP链接,也就是HTTPS。相对于TLS1.2来说,TLS1.3在安全和执行效率上都有提高。


在安全上,TLS 1.3放弃了很多原来TLS 1.2上的加密算法,毕竟都是上世纪90年代的算法了,现在那些算法安全性已经不高了。例如SHA-1和RC4就已经不用了。


在速度上,TLS 1.3主要是通过减少握手次数来实现速度提升的。TLS 1.2上的两轮握手在TLS 1.3上只需要一轮就可以建立连接了。



4.2 QUIC


Google搞了个QUIC,苹果在跟进。QUIC可以理解成UDP实现的TCP+TLS+HTTP/2集合体。主要是提高了数据传输效率和链接效率:


  1. 极大降低了链接建立的时间
  2. 增强了拥塞控制机制
  3. 无阻塞的多路传输
  4. 通过数据冗余传送来实现的错误控制机制(接收端会识别重复数据从而将其筛掉,最终使得错误率尽可能低)
  5. 链接迁移(由于QUIC是UDP实现的,因此一个“链接”并不要求一定是端对端,可以几台设备同时处理一个“链接”上的数据)


目前QUIC的开发才刚刚开始,项目网站提供了玩具客户端和玩具服务端给大家玩:Playing with QUIC





总结

我个人比较喜欢的是设备支持了多路多协议的网络操作,这个功能可以极大地提高应用体验。不过也会有用户认为这个功能会导致额外的流量消耗,这就比较矛盾了。


我个人比较讨厌的是不允许delegate和block同时使用的这一项,相信大家也没少被这个事情坑过。因为delegate和block都有各自的适用场景,苹果这么一做,哪怕API调用时的业务场景是一样的,但只要适用场景不同,就也得创建多个NSURLSession了,否则使用起来就很别扭。我可以理解苹果这么做的目的是认为有些事件不适合做两次:delegate一次,block又一次。但这种做法其实并没有必要,应该由使用者来决定在delegate和block都有实现时,应该如何处理:是两个都调、只调block不调delegate、只调delegte不调block。


总的来说这一轮苹果对NSURLSession的改造都很中规中矩,即便是给的新功能,绝大多数也都是偏优化方向。




参考

一种基于ResponderChain的对象交互方式




前言

实现基于ResponderChain的对象交互

结合Strategy模式进行更好的事件处理

结合Decorator模式

分析基于ReponderChain的对象交互方式

总结





前言



传统iOS的对象间交互模式就那么几种:直接property传值、delegate、KVO、block、protocol、多态、Target-Action。但是有一天我在跟同事小龙结对编程的时候,他向我介绍了一个全新的交互方式:基于ResponderChain来实现对象间交互。


这种方式通过在UIResponder上挂一个category,使得事件和参数可以沿着responder chain逐步传递。


这相当于借用responder chain实现了一个自己的事件传递链。这在事件需要层层传递的时候特别好用,然而这种对象交互方式的有效场景仅限于在responder chain上的UIResponder对象上。





实现基于ResponderChain的对象交互



仅需要一个category就可以实现基于ResponderChain的对象交互。


.h文件:


#import <UIKit/UIKit.h>

@interface UIResponder (Router)

- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo;

@end



.m文件


#import "UIResponder+Router.h"

@implementation UIResponder (Router)

- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{
    [[self nextResponder] routerEventWithName:eventName userInfo:userInfo];
}

@end



发送事件时:


[self routerEventWithName:kBLGoodsDetailBottomBarEventTappedBuyButton userInfo:nil];



响应事件时:


#pragma mark - event response
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{

    /*
        do things you want
    */

    // 如果需要让事件继续往上传递,则调用下面的语句
    // [super routerEventWithName:eventName userInfo:userInfo];
}





结合Strategy模式进行更好的事件处理



在上面的Demo中,如果事件来源有多个,那就无法避免需要if-else语句来针对具体事件作相应的处理。这种情况下,会导致if-else语句极多。所以,可以考虑采用strategy模式来消除if-else语句。


#pragma mark - event response
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{

    NSInvocation *invocation = self.eventStrategy[eventName];
    [invocation setArgument:&userInfo atIndex:2];
    [invocation invoke];

    // 如果需要让事件继续往上传递,则调用下面的语句
    // [super routerEventWithName:eventName userInfo:userInfo];
}


self.eventStrategy是一个字典,这个字典以eventName作key,对应的处理逻辑封装成NSInvocation来做value。


- (NSDictionary <NSString *, NSInvocation *> *)eventStrategy
{
    if (_eventStrategy == nil) {
        _eventStrategy = @{
                               kBLGoodsDetailTicketEvent:[self createInvocationWithSelector:@selector(ticketEvent:)],
                               kBLGoodsDetailPromotionEvent:[self createInvocationWithSelector:@selector(promotionEvent:)],
                               kBLGoodsDetailScoreEvent:[self createInvocationWithSelector:@selector(scoreEvent:)],
                               kBLGoodsDetailTargetAddressEvent:[self createInvocationWithSelector:@selector(targetAddressEvent:)],
                               kBLGoodsDetailServiceEvent:[self createInvocationWithSelector:@selector(serviceEvent:)],
                               kBLGoodsDetailSKUSelectionEvent:[self createInvocationWithSelector:@selector(skuSelectionEvent:)],
                               };
    }
    return _eventStrategy;
}


在这种场合下使用Strategy模式,即可避免多事件处理场景下导致的冗长if-else语句。





结合Decorator模式



在事件层层向上传递的时候,每一层都可以往UserInfo这个dictionary中添加数据。那么到了最终事件处理的时候,就能收集到各层综合得到的数据,从而完成最终的事件处理。


#pragma mark - event response
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{
    NSMutableDictionary *decoratedUserInfo = [[NSMutableDictionary alloc] initWithDictionary:userInfo];
    decoratedUserInfo[@"newParam"] = @"new param"; // 添加数据
    [super routerEventWithName:eventName userInfo:decoratedUserInfo]; // 往上继续传递
}





分析基于ReponderChain的对象交互方式



这种对象交互方式的缺点显而易见,它只能对存在于Reponder Chain上的UIResponder对象起作用。


优点倒是也有蛮多:


  • 以前靠delegate层层传递的方案,可以改为这种基于Responder Chain的方式来传递。在复杂UI层级的页面中,这种方式可以避免无谓的delegate声明。
  • 由于众多自定义事件都通过这种方式做了传递,就使得事件处理的逻辑得到归拢。在这个方法里面下断点就能够管理所有的事件处理。
  • 使用Strategy模式优化之后,UIViewController/UIView的事件响应逻辑得到了很好的管理,响应逻辑不会零散到各处地方。
  • 在此基础上使用Decorator模式,能够更加有序地收集、归拢数据,降低了工程的维护成本。


基于ResponderChain的对象交互方式的适用场景首先要求事件的产生和处理的对象都必须在Responder Chain上,这一点前面已经说过,我就不再赘述了。


它的适用场景还有一个值得说的地方,就是它可以无视命名域的存在。如果采用传统的delegate层层传递的方式,由于delegate需要protocol的声明,因此就无法做到命名域隔离。但如果走Responder Chain,即使是另一个UI组件产生了事件,这个事件就可以被传递到其他组件的UI上。


举个例子:XXXViewController属于A组件,这个UIViewController的view上添加了B组件的某个YYView。那么YYView的事件在通过Responder Chain被XXXViewController处理的时候,就可以不必依赖B组件的YYView了。当然,如果事件本身传递了只有B组件才有的对象,无视命名域这个优点就没有了,不过这种场景在实际业务中其实也不多。


最后要说的是,由于事件被独立了出来,它可以极大减轻MVC中C的负担。在我们实际工程使用中,我创建了EventProxy对象,专门用于处理Responder Chain上传递的事件:


#pragma mark - event response
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{
    [self.eventProxy handleEvent:eventName userInfo:userInfo];
}


在这种场景下就做到了UI展示与事件处理的分离,事件处理的代码就可以归拢到另外一个对象中去了,使得C的代码量得以减少。


或许可以算是一种新的架构模式:MVCE(Modle View Controller Event)?其实名字、模式什么的都已经不重要了,毕竟所有的架构模式都是脱胎于MVC的,作不同程度的拆解罢了。





总结



这个交互方式是我的同事小龙告诉我的,我觉得很有意思。在实际工程中应用起来也十分得心应手,尤其是UI复杂且事件数量极多的场景,拿它来处理多事件逻辑是十分合适的。


我们在商品详情页中使用了这种对象交互方式:商品详情页有各种cell,每个cell上面又有各种button事件,每个Cell也有各自的子View,子View中也有button事件需要传递,而cell本身也需要相应点击事件。在这种复杂且多层级UI事件场景下,如果用delegate的方式层层传递,代码确实不如用Responder Chain的事件交互方式容易维护。用block的话,事件处理逻辑就会被分散在各个对象生成的地方。用Notification则更加不合适了,毕竟它并不属于一对多的逻辑,如若其他业务工程师在其它地方也监听了这个Notification,事件处理逻辑就会变得极为难以管理。


所以我写了这篇文章介绍了一下这个方式,希望能够在大家日常开发遇到类似场景时提供一点儿帮助。



对象间交互模式




前言

直白的对象间交互

轻度抽象的对象间交互

使用Target-Action淡化接口概念的对象间交互

响应式的对象间交互

基于闭包的对象间交互

推导过程

总结





前言



闭包在什么场景下才是最优选择?理由?补充说明一下:很多场景都适用闭包,但不是所有场景下闭包都是最优选择。


这篇文章主要就是回答这个问题的。


但是在直接切入闭包之前,我们需要有一个推导的过程,在推导过程之前,我们还需要梳理一下现有的交互模式。所以,关于这篇文章的内容,更确切地说应该是讨论了对象间各种交互方式。


这个主题其实很早之前业界也讨论过,objc.io很早就有《Communication Patterns》这篇文章详细讲过这个问题。这篇文章在很多地方说得都是对的,但这篇文章在block的讨论上不够全面。国内也有很多人关于对象间交互的问题写过文章,但这些文章相比《Communication Patterns》来说,就已经错得十分离谱了。


本文是从另一个角度讨论了同样的问题,文末给的决策图可以作为《Communication Patterns》的补充去做参考。





直白的对象间交互



解决对象间交互问题的最直白朴素的做法就是直接传值,直接调用。


适用这种做法的前提是你非常确认A对象的信息来源只可能是B对象,而且在未来这个前提也是不会产生任何改变。这个前提在某些场景下是成立的,例如你要取timestamp,那么只可能从NSDate中取。你要操作字符串,也只可能使用NSString。我这里说的是通常的环境,当然,针对NSDate或NSString这类对象做二次封装导致不直接依赖NSDate或NSString的场景也是有的,其本质依旧不变。


这种做法的好处在于可以限制实现手段的多样性,在维护代码的时候工程师能够更加容易地去聚焦问题所在。


存在限制不一定是坏的,灵活度高也不一定是好的。当一个工程中为了解决同一个问题而存在多种方案的时候,存在多种方案往往会给工程维护带来麻烦。尤其是业务上下文对灵活性不存在任何要求的时候,此时引入灵活性带来的权衡就是代码维护困难,于是修复Bug的成本就会随之增加。


这种做法的坏处也是显而易见,当业务上下文对灵活性有要求时,也就是A对象的信息来源只可能是B对象这个前提不满足时,这种做法就会导致业务工程师不得不深入了解实现细节,然后修改,才能不断地去迎合业务需求的变化。一般来说,这种深入修改的成本很大,引发其它bug的几率也会变大。





轻度抽象的对象间交互



为了能够迎合业务需求的变化,我们就需要针对之前最直白朴素的对象间交互方式做一次抽象提炼。


有两种方式可以做抽象,一种是使用多态的方式,把需要调度的方法或数据写在基类中,让调度者声明基类。然后响应者派生自基类,重载基类相关方法,从而达到调度者在无需知道响应者具体类型的情况下完成调度的目的。


另外一种做法是使用接口/协议(interface/protocol,后面我就都用接口来表述了)的方式,让调度者声明一个接口,然后响应者实现这个接口。这样也能同样做到调度者在无需知道响应者具体类型的情况下完成调度的任务。


单就多态和接口两种方法来看,也是各有千秋。


多态方案的优点在于基类可以提供默认的实现。也就是说,即使响应者不重载基类,也能给调度者提供默认实现。在这一点上,接口方案就不够好注1在支持多继承的语言中(例如C++),多态方案下的一个响应者可以同时继承多个不同领域的基类,这也意味着这个对象可以同时扮演多个调度者的响应者。但是现在绝大多数语言都摒弃了多重继承,主要是多重继承的引入会带来更多额外的问题,例如菱形继承(也叫钻石继承,因为钻石是菱形的)导致的对象父子关系复杂、同名父类方法导致的二义性等。所以,在不支持多继承的语言中,多态方案的缺点就变成了响应者只能够被一个调度者去调用,这就给基于这种方案的对象间交互模式提出了一个前提:一个对象只能作为一类调度者的响应者。然而事实上满足这种前提的场景并不多。


接口方案就没有这样的问题,只要一个对象实现了多个接口,那么这个对象就能够被多个不同的调度者去调度。而且即使多个接口中定义了同名函数,也不会产生二义性,因为一个对象不可能实现同名的两个函数(此处的同名表示函数名和参数列表都相同)。唯一不足的地方就是前面已经提到的:接口方案无法给调度者提供默认实现,这就导致了调度者每次都要确保某个接口方法确实被实现了,才能走下一步的调度操作。


另外补充说明一下,多个接口中定义同名函数的做法在某种程度上也是合理的。尤其是在基于鸭子模型的实现场景下,多个接口中定义同名函数是很普遍的状况。这能够更近一步地淡化对象类型,可以为业务实现提供更高的抽象度。Go在这一方面就做得非常好。


在实际场景中,我更多的是倾向于使用接口方案,即使一个对象只能作为一类调度者的响应者这个前提成立,我也不会选择使用多态方案。虽然使用接口方案时,我必须要确保响应者实现了对应的接口方法,但接口方案的侵入性更加少,它不会为原来的对象额外引入其它我不需要的实现,同时,组件化推进过程中抽离一个接口声明要比抽离一个基类实现要容易得多,因为你不会知道这个基类还依赖了哪些其它乱七八糟的东西。





使用Target-Action淡化接口概念的对象间交互



使用接口方案还会有另外一个问题,就是一个接口的定义很少情况下只有一个方法声明。往往是你引入了一个接口,就相当于引入了这个接口的全家桶。虽然可以使用required关键字来指定某些方法是否必须实现,但如果遇到我们需要的方法不是必须实现的,而必须实现的方法都是我们不需要的情况的时候,就很蛋疼了。


另一方面,有的时候一个接口定义并不足以完全表达响应者应该做的事情。例如页面上控件的事件响应,事件来源可以是手势、可以是按钮等,且手势和按钮在不同的页面上数量都是不同的。这种情况就很难定义一个所谓的EventResponse这样的接口去给响应者。


为了解决全家桶问题和无法完整定义所有事件的问题,我们就可以采用Target-Action的方式来解决。事实上Target-Action就是把每个调用从全家桶里面拿出来了,一些比较不复杂的回调,使用Target-Action就可以了。


一方面Target-Action可以在某种程度上理解成简化版的接口,另一方面也可以借助runtime的特性(也就是说前提是语言支持),Target-Action可以做到完全解耦,同时无视命名域是否完整。因为无论是之前讨论的多态方案还是接口方案,都需要针对对象或接口提供声明。只有命名域中包含这些声明的内容,代码才能够使用得了相关的对象和接口。而Target-Action方案下,Target和Action都无需额外的类型声明,只要手上有Target指针,有Action描述即可。


在Target-Action结合runtime的场景下,命名域无需覆盖到相关的声明,就也能够完成调用。然而其随之而来的权衡就是失去了编译器检查。在这种场景下使用Target-Action是对应用场合十分挑剔的,必须是不经常变动的业务和代码才适用这种方案。





响应式的对象间交互



前面说的都是基于命令式的思路去做的对象交互方案,基于响应式的思路也是可以设计对象交互方案的。


这里先来说一下响应式响应式函数式往往会混为一谈,但实际上两者只是互相利用的关系,并不是同一个概念。响应式对应于命令式的差别就在于主动性的差别,举一个日常生活的例子会更容易理解:


假设你现在是主人,你肚子饿了。你需要让你的仆人们给你做饭,端过来给你吃。


在命令式思路的方案下,若要完成这个任务,你需要这么做:


1. 对仆人中负责买菜的人吩咐你去买菜带给你
2. 你拿着仆人买来的菜给到负责做菜的仆人吩咐他去做菜
3. 厨师做好菜端给你你就可以吃了


在响应式思路的方案下,若要完成这个任务,你需要这么做:


你喊一声:我肚子饿了!

1. 负责买菜的仆人听到你喊的这一声,就去买菜了,买好菜就放到仓库里,然后喊一声:"我买好菜了!"
2. 负责做菜的仆人听到买菜的仆人喊的这一声,就去仓库里拿东西做菜,做好了端给你。


这里可以看出响应式和命令式的一个最重要区别:在命令式场景中,仆人是被动的,需要你去吩咐。在响应式的场景中,仆人是主动的,听到一声喊就知道该去做什么事情。而仆人做事情的方法,往往是通过函数式的方式由你"教会"的(当然,仆人也可以天生就会,不需要函数式去辅助。),所以说函数式和响应式不应该混为一谈,他们事实上是互相利用的关系。思路拉回来,响应式和命令式在主动性上的区别就带来了一个这样的结果:响应式中主人无需认识仆人,只需要喊一声就可以了。


在最初的时候,响应式的思路是为了解决C10K问题而诞生的。在大规模服务器集群中,由于集群中的服务器是动态上线、动态扩容的,所以调度者不可能一个一个地去主动调用集群中的单个服务器,因为它没办法认识每一台服务器,所以只好发一个信号出去让关心这个信号的服务器去响应信号,也就是喊一声,然后最终完成任务。而且由于调用方自己会喊一声,那就避免了集群中的服务器一个一个去轮询的问题,轮询带来的性能消耗对于服务端来说是很吃不消的。


响应式思路发展到客户端,更多的是利用了响应式思路中主人无需认识仆人的这个特点,使得工程能够在较低耦合的情况下完成原来的任务。当然,模块的动态挂载也是比较常见的情况,例如页面出现时响应通知,页面消失时不响应通知也是很常见的情况。但这只是业务角度的特征,从架构角度去思考问题的话,我们更加侧重于响应式带来的低耦合的特点。然后只有从性能角度去讲的时候,我们才会去关心避免轮询的特点。如果前面的例子中,仆人闲着没事就来问你一句你饿不饿,你是不是得烦死?


顺便扯一句,如果下一次有人问你delegate和notification有什么区别的时候,说一对一和一对多的区别就很low了,delegate模式也是能够实现出一对多的功能的(例如XMPP的multi delegate)。他们之间的本质区别就在于命令式和响应式。而且他们俩在避免轮询的角度上讲,是一模一样的,没有差别。


另外,响应式的本质其实就是Observer模式的应用。因此,Notification/Signal/Key-Value Observe就都可以被归类到响应式中。iOS场景中的KVO虽然也是属于响应式,但它的设计其实更多的是为了解决轮询问题,对于架构而言帮助不大。


前面我们说到响应式能在较低耦合的情况下完成对象间交互,这个较低耦合在两个方面体现:



  1. 相对于最原始的交互方式,响应式对命名域的要求更少,只需要有String即可。另外,在对命名域的要求上看,它是跟接口方案对命名域的要求是一样的,不需要引入完整的一个对象。所以从命名域的角度讲,它比多态方案要好,跟接口方案一样好,比Target-Action方案差。

  2. 相对于前面所有的方案而言,响应式方案是不需要知道响应对象地址的,否则就无法完成调用。在响应式中,调用者虽然已经不需要知道响应对象的实例以及命名域了,但响应对象仍旧需要知道调用者相关的命名域,否则就无法响应调用者释放的信号。





基于闭包的对象间交互



闭包和其他所有方案比起来,最大的区别在于它能够抓取当前上下文的命名域,这也意味着:使用闭包的调用者完全不需要知道响应者的上下文,因为闭包已经把相关上下文都抓取了。响应者在闭包的创建和传递过程中,也可以做到完全不需要知道调用者的命名域。


举一个面包机的例子:


你是调用者,你想吃面包,虽然你不会做面包,但你会给电器插电源。

厨师是响应者,他把面粉、鸡蛋、牛奶、糖都放进面包机里,并且预先设置好程序,确保插上电源就能做面包。然后把面包机丢给你。

你想吃面包时,你拿到一个不知道是什么机器的东西,但因为你会插电源,所以你把电源插上,面包机程序跑完之后,你就有面包吃了。

在这整个过程中,你既不知道是谁给你的面包机,也不知道面包机里面都装了些什么。你就是个傻子,只知道拿上这个玩意儿插上电源等一会儿就有面包吃。

面粉、鸡蛋、牛奶、糖就是制作面包时候所需要的上下文,但你不用管这些,这都是已经抓好了的。


鉴于闭包能够抓取命名域的特性,就使得闭包可以在两个对象间命名域不完整的情况下,完成对象间交互。例如在跨组件调用中,调用者和响应者都互相不知道对方的命名域,于是在互相都不认识的情况下,他们俩之间的事件传递就只能通过闭包进行了。对于数据传递来说,这种场合下也可以使用NSDictionary,因为NSDictionary肯定是完整覆盖命名域的。也可以通过闭包参数传递过去,具体选择哪种还是要看具体业务了。


对于闭包的应用来说,又分两个场景:响应者使用闭包调用者使用闭包


在前面面包机的例子中,就是调用者使用闭包的情况,此时闭包抓取的是响应者的上下文。也就是鸡蛋面粉那些。


在组件间调用的时候,其实是响应者在使用闭包,因为在调用者希望响应者做的事情里,会涉及调用者的命名域。而响应者没办法获取调用者的命名域。





推导过程



我们从最原始直白的对象间交互开始推导。


由于这种最原始直白的交互方案要求对象必须要互相知道对方的具体类型。在有些场合中,这种前提是不满足的。所以为了解决这个问题,就有了基于多态的方案和基于接口的方案。基于多态的方案使得调用者只要知道基类的声明,基于接口的方案只要知道接口的声明即可,而无需知道具体响应者的类型,这就带来了一定程度的灵活性,使得响应者不再受类型的限制。


但即便是多态方案和接口方案,调用者仍旧需要知道响应者的命名域。为了能够让调用者不必知道响应者的命名域,我们就可以用Target-Action的方式,拿到响应者的实例而不必管具体的类型是什么,也无需引入冗余的接口的声明或基类。需要注意的是,Target-Action其本质仍然是基于多态的方案,只不过通常情况下我们的子类采用的是父类的默认实现而已。此时就谈不上多态了,就只是调用父类方法而已。


在Target-Action方案中,仍然有一个小遗憾。那就是调用者虽然可以不必了解响应者是什么对象,但调用者必须要拿到响应者实例。就相当于你要找人办事,你并不知道这个人的名字,甚至也不知道这个人是男是女,但你手上必须要有这个人,你才能办事。为了解决这样的问题,就有了基于响应式的对象间交互方案。


在响应式对象间交互方案下,你再要找人办事,就已经不需要手上要有这个人了。你只需要吼一声,说你要办什么事。只要有人在听,自然就会把事情去办掉了。但即使是在这种情况下,仍然还可以在业务场景中找得到缺陷:响应者必须要知道该听什么指令,而且响应者做的事情必须不能脱离命名域。缺陷就在于这个指令,指令可以体现为NotificationName,也可以体现为Signal。总之,这个指令必须是调用者知道,且响应者也能识别的。响应式往往可以达到模块间跨层级的调度,正是因为指令的命名域覆盖了所有层级。那么,当命名域是残缺不全的,无法覆盖所有层级时,且响应者要做的事情所需要的上下文正好又没有被命名域覆盖到,这时候就蛋疼了。


正因为闭包可以抓取上下文,跨越命名域传递,从而使得在命名域残缺不全的情况下完成对象间交互成为可能。当然,这里的前提是闭包的参数列表里面的参数不要受命名域的影响。所以很明显,闭包的最佳使用场景就是命名域残缺不全


讲到这里,你是不是会觉得:卧槽,看来闭包是最强大的对象间交互方案了,以后我所有的对象间交互就都用闭包去完成!


千万不要有这种想法,接下来我们再从基于闭包的对象间交互方案来推导,你就会明白这个道理:一定要在合适的场景选择合适的方案


闭包方案在命名域残缺不全的同时,就带来了一个这样的限制:发起调用的地方和提供回调的地方必须要在同一上下文。这个限制具有两面性,很多人只看到了发起调用和提供回调的地方在同一上下文时的便利,因为哪儿调用的你就在哪儿能够看到回调。但没有意识到这对于程序结构其实是一种限制,在很多场合,我们是不希望发起调用和提供回调是同一个地方的。举个例子就是UITableView的DataSource,正因为DataSource的设计采用了delegate模式,才使得DataSource可以被独立成为一个对象,从而降低ViewController的代码量。还有一个例子就是之前我给出的网络层架构方案,因为很多场合下,发起API调用的人其实并不关心API回调的数据,真正关心API回调数据的有可能是View也有可能是ViewModel或其它的一些对象(例如数据同步对象)。所以此时使用闭包就对这一场景产生了限制。


注意,上面举例的更多是delegate模式,并不意味着只有delegate模式才能做到对象剥离这一点。在除了闭包方案的其它所有方案中,它们都不强制要求发起调用的地方和提供回调的地方必须要在同一上下文。同时还要注意的是,并不是说不用闭包之后,剩下的方案就可以随意选择了。现在我们就从响应式对象交互方案开始推起。


响应式对象交互方案要求响应者必须注册通知或监听信号,随之而来的就是响应者取消注册和取消监听的时机也需要进行管理。这件事情要是做得不干净,是会导致crash的。不过这只是操作上的问题,细心点就能解决,这一点在架构上并不是问题。在架构上真正成为问题的地方在于,响应式方案有可能会造成跨层数据传递,进而导致数据流的动向产生混乱。当底层对数据流有控制的要求的时候,我们是不希望上层(或者底层的其他组件)也能插手数据流的管理的。否则这就是开了天窗,在未来重构的时候就很难整理数据流的流向了。这就相当于你吼了一声想要吃饭,结果不光你屋子里的仆人听见了,别的屋子里的仆人也听见了(比如有另一个人想在你吃饭的时候也一起吃饭,他自己不喊,让他的仆人听你的喊话),这就造成了别的屋子里的仆人也对你产生了依赖,后面如果要分家,就很难理清楚到底应该让哪些仆人听你的,哪些仆人不听你的。实际场合中这样的例子很多,如果你监听了UITextField的通知,你就必须要区分喊话的这个UITextField是不是你关心的那个人。再比如你监听了Application生命周期相关的通知,如果将来要对这部分进行整理,就是一件比较麻烦的事情。所以响应式方案更加适用于对数据流的管理没那么多要求的场合,反正喊了话出去之后我才不管你们怎么干呢,这种场景就比较适合响应式。


所以当你对数据流有比较强的管理的要求的时候,使用响应式方案去处理对象间交互就会比较蛋疼。这时候我们就需要对响应者有更严格的限制,才能保证对数据流的管理。于是我们就只剩下Target-Action多态方案接口方案原始方案可选了。


Target-Action方案和其它方案的辨析其实上面已经说过了,当我们无法提供一个完整的能够cover住所有事件的声明(基类声明或接口声明)时,就适用Target-Action方案。但如果我们能够提供声明,那么就更加适合使用多态方案接口方案,因为他们能够给我们带来编译期检查。但编译期检查这一特征放在架构角度去考虑的话,其实并不能算是优势,因为这是两个维度的事情,不能放在一起讨论。多态方案接口方案在架构角度提供的优势是,它能够很清晰地告知一个响应者应该如何被实现,调用者针对响应者的要求能够以声明的方式暴露出去,从而提供了响应者的管理手段:管理声明即可。在未来的迭代或重构中,通过声明的修改再结合编译期检查的功能,就能够快速响应迭代的变化,提高了架构方案的适应性。


多态方案接口方案两者之间,也是需要做辩证的。这个辩证的关键点在于响应者角色。如果某一个调用所有的响应者角色在业务场景下都是固定的,那么就可以采用多态方案。如果这个角色是不固定的,那就必须采用接口方案。这很明显是因为接口方案不会限制对象类型,多态方案会对响应者对象类型产生限制造成的。但多态方案也并非一无是处,在多态方案下,基类可以为optional的方法提供默认实现,接口方案就无法做到这一点,默认实现只能写在调用者中,或者索性就不实现了。在optional的方法不需要响应者提供实现的场合下,你还是得用多态方案


多态方案接口方案下,它们都抽象了响应者的类型。在原始方案中,响应者的类型是完整且清晰的,也就是具象的。当我们十分确定响应者的身份时,我们就没必要使用多态方案接口方案了。原始方案虽然限制最多,但是其适用场景其实是最普遍的,因为绝大多数场景下我们是能够保证响应者的唯一性。例如你使用SDWebImage去加载图片,使用AFNetworking去发请求,使用NSString来表达字符串,这些其实都是原始方案下的对象间交互。除非你有SDWebImage加载不了的图片,AFNetworking发送不了的请求,或者你需要频繁改底层,你才需要使用更加抽象的其它方案。





总结



我也画了一幅图,可能不完整,但应该能够大致表达出意思。这幅图背后的思想的切入点是跟《Communication Patterns》不一样的,所以考虑的条件也不会一样。但两者在本质上不存在冲突,大家可以拼在一起看。


pattern





注:

注1:在swift语言的实现中,接口是可以提供默认实现的。对于这种情况我还没想明白应该是属于哪种定位,所以在本文中难以讨论,因为这种实现两边都讨好。在swift这种方式的实际使用场景中,多态方案的适用面就变更小了,在做决策的时候,侧重点就更加偏向于对象的角色定位。back







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




前述



国内业界大家对组件化的讨论从今年年初开始到年尾,不外乎两个方案: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




惰性计算辨析


其实应该叫惰性求值(Lazy Evaluation)比较标准。






就在大约一两个小时之前,有一位我博客的读者在评论区里留言,提到最近臧成威写了一篇《聊一聊iOS开发中的惰性计算》,里面提到了一个观点是除了创建非常大的属性、或者创建对象的时候有一些必要的副作用不能提前创建之外,几乎不应该使用惰性求值来处理类似逻辑。。并且主要给出了六点理由。他给出的结论和我提倡的做法相悖,问我是什么看法。


在这里我先离题一下:我完整地看完了这篇文章,很喜欢这种立场鲜明,而且有清晰理由的文章。我先不说理由是否合理,立场是否正确,至少我看到国内技术圈子里的大多数文章其实没有任何观点和立场,都只是教你XXX怎么用,而且写得又不比官方文档好,含金量很低。即便在少数有观点的文章中,大部分又只有观点,没有任何理由。臧成威这篇文章是逻辑清晰,有观点而且有理由的,这篇文章在这一点上其实是做的很好的。


那么,接下来我就要在这篇文章中辨析一下他的文章里提供的六个理由了。






如果真的是很大的属性,一般它比较重要,几乎一定会被访问,所以加上这个不如直接在 init 的时候创建。


这个理由其实是有逻辑问题的。


虽然这句话并没有很绝对地说很大的属性就一定比较重要,给你造成一种看起来说得很客观,很有道理的假象。更何况,事实上一个属性的重要程度其实是和属性本身的大小也是无关的。


但另外一点是,惰性求值并不影响属性的可访问性,即使前面属性很大属性很重要属性一定会被访问都满足,我实在看不出这三个条件能够给出在init的时候创建的倾向。惰性求值和及早求值的差别完全不在那三点前提,这里的理由和给出的结论其实是完全无关的。


这句话其实就是类似这样的句子:因为西瓜很大,所以西瓜一般比较重要,而且夏天基本上一定都会吃西瓜,所以西瓜还不如直接用小卡车运进城,就不要用拖拉机了。 我再离题一下:这种话术其实很有欺骗性,我们国家也经常采用这种话术来欺骗百姓,不过这里我们不谈国事,你懂的。


总结来说就是,臧成威的这条理由根本无法去支撑他的论点,这本质上并不是技术问题,是思维逻辑问题,我不去揣测臧成威的动机是故意还是无意的,我只指出这逻辑是错的。




@property 的 atomic、nonatomic、copy、strong 等描述在有 getter 方法的属性上会失效,后人修改代码的时候可能只改了 @property 声明,并不会记得改 getter,于是隐患就这样埋下了。


我当时看到这个理由的时候我非常吃惊,除了atomic和nonatomic以外,其它的其实都是修饰setter的啊,为什么用了getter就失效了?这不合常识啊。于是我做了求证:


首先,Strong/Weak 在getter中编译器是会warning的,从编译器的warning上看,谈不上失效。看下面的例子:


@interface ViewController ()

@property (nonatomic, weak) NSArray *testArray;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    NSLog(@"%@", self.testArray);
}

#pragma mark - getters and setters
- (NSArray *)testArray
{
    if (_testArray == nil) {
        _testArray = [[NSArray alloc] init]; // 此处会报warning: Assigning retained object to weak variable; object will be released after assignment.
    }
    return _testArray;
}

@end




然后,copy在有getter方法的属性上也不会失效,因为copy完全修饰的是setter方法,与getter无关。看下面的例子:


@interface ViewController ()

@property (nonatomic, copy) NSArray *testArray;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    NSMutableArray *aArray = [[NSMutableArray alloc] initWithObjects:@123, nil];
    self.testArray = aArray;

    [aArray addObject:@456];

    NSLog(@"%@", aArray); // 输出123,456
    NSLog(@"%@", self.testArray); // 输出123,而不是123,456。证明copy并没有失效,如果copy失效,那应该也输出123,456。
}

#pragma mark - getters and setters
- (NSArray *)testArray
{
    if (_testArray == nil) {
        _testArray = [[NSArray alloc] init];
    }
    return _testArray;
}

@end




最后是nonatomic和atomic,这个我现在并不知道有什么比较好的手段去求证这个问题,我现在比较忙并没有时间去查资料。但针对这个情况我要说两点:


  1. 实际开发工作中基本上都是用的nonatomic去修饰一个property,如果真的要进行原子操作,往往是自己用锁来建立临界区,很少情况是用atomic。原因见第二条:
  2. 因为atomic并不能保证线程安全,线程安全应当由工程师自己通过锁来建立临界区。我记得这个描述苹果官方文档有说过,并且举了一个Person对象的例子。实际出处的链接我一时半会儿找不到了,大家如果有空的话可以帮我找一下。


针对第二条有网友补充:在多个atomic property的情况下,atomic并不能保证他们取值赋值的时序,因此不能保证线程安全。但对于单个property而言,atomic是安全的。在实际工作中,往往临界区涉及的属性和数据并不惟一,因此实际开发场景都是推荐工程师自建临界区,另一个角度上,这也方便将来增加或删除临界区相关的变量。


所以如果要自建临界区的话,其实用getter只会比不用getter更好,因为临界区里面涉及的逻辑和变量有可能很复杂,而我们并不希望这部分复杂的代码泄漏到与之无关的主要逻辑中去,这样会使得主要逻辑不清晰,难以维护。




代码含有了隐私操作,尤其 getter 中再混杂了各种逻辑,使得程序出现问题非常不好排查。后人哪会想到someObj.someProperty这样一个简简单单的取属性发生了很多奇妙的事。


是否要在getter中写逻辑,这其实是一个主观问题。


如果你决定要在getter中写逻辑,那么就应当只写跟初始化过程有关的逻辑,跟初始化过程无关的逻辑就不要在getter里面写。因为getter本质上其实是工厂方法,工厂方法是不应当跟业务掺杂过多的。


实际开发过程中,确实有人把不必要的逻辑写进getter中,这些都是我在code review的过程会打回让他重写的。一般新人进我的team都会有一个月的code review过程来进行教育,所以乱写的情况很少。


最后,这本质上是一个主观问题并不是一个客观技术问题,更不属于getter的技术缺陷,真要说技术缺陷的话,上面一条更加类似。而且,对于一个傻逼来说,不管使不使用getter,他都一样会给你写出难以排查问题的代码。所以真正要做的事情是把傻逼教好,而不是不使用getter。


这叫因噎废食,傻逼不会吃饭噎到自己了,不去考虑怎么学习吃饭的正确方法,反而决定不吃饭了。




很多人的 getter 写得并不是完全标准,例如上述代码会导致多线程访问的时候,出现很多神奇的问题。一旦形成习惯,后续的很多稀奇古怪的 crash 就接踵而至了。


这个结论其实并没有详细的理由去支撑。神奇的问题具体是什么?跟getter有关吗?在我做过的项目中,由于使用了getter,排查问题时就能够非常有目的性,只要先搞清楚是变量初始化的问题,还是逻辑操作的流程问题,就基本上能够很快定位到问题点了。


这种模糊表达的话术,其实就是典型的当年秦桧的莫须有。嗯,有crash出来了,而且莫名其妙,所以,可能就是getter导致的吧?这个还真难反驳,但如果臧成威你遇到了这个神奇的问题,且与getter有关,那就列举出来,然后我们再来就这个理由继续讨论。在此之前,这个锅getter表示不背。


至于getter写得不标准,其实我在上一条里面已经说清楚了:即使你不写getter,傻逼们一样会给你搞出各种莫名其妙的crash,相信你即使不去blame getter,也会去blame 其它。




代码多,本来代码只需要在init方法中创建用上一两行,结果用了至少 7 行的一个 getter 方法才能写出来,想想一个程序轻则数百个属性,都这么搞,得多出多少行代码?另外代码格式几乎完全一样,不符合 DRY 原则。好的程序员不应该总是写重复的代码,不是么?


其实这个问题其实是这样的,使用getter和不使用getter,在代码行数上的差别仅多出5行,剩下的其实都一样。


然后一个程序轻则数百个属性,这个我是不认可的,一个程序里面,20-30个属性已经算是非常大了,我真从来没见过有哪个对象有数百个属性的,如果真的存在,那说明这个工程的模块划分、对象划分存在问题,这是一个比使用和不使用getter都更加严重的问题。即使你不使用getter,如果遇到了数百属性的对象,首先要做的事情也必须是重新考虑模块划分和对象划分。而且再退一步说,一个对象中也不是每个属性都要有getter的。


臧成威的这种说法其实是潜移默化地在扩大范围,如果所有XXX都这么搞,那得XXX?这个话术其实就是在夸大问题范围,以前别人写过一篇文章讨论过这种话术,不过我现在也找不到出处了。


而且,就这个问题来看,使用getter的好处十分明显,一个程序的初始化区域和逻辑执行区域被分隔开了,这样就能使得即使很多行代码的文件,其代码分配结构就会变得非常清晰。所以即使在非常大的对象里,使用getter来划分代码在文件中的组织结构,是非常有利于大对象维护的。


最后,关于DRY,臧成威你是不是不知道XCode有自定义的code snippt功能?我觉得至少你应该在写GCD相关代码的时候也用过吧?谈何重复?




性能损耗,对于属性取值可能会非常的频繁,如果所有的属性取值之前都经过一个if判断,这不是平白浪费的性能?


这里的性能损耗其实是一个权衡问题,也是使用惰性求值和及早求值的主要差别之一。在不使用惰性求值的时候,程序的内存foot print会因为一个对象的初始化而形成一个陡峭的曲线。使用惰性求值的好处在于能够避免不必要的内存占用。在整个程序的生命周期上,能够提高内存的使用效率,在生命周期中的某个时间维度上,可以保证后续逻辑的高效完成。


举个例子,一条逻辑分别有A,B,C三项任务构成,分别需要使用a,b,c三个属性。假设内存一次只能装得下三个属性中的任意两个,如果不使用惰性计算,这个程序的内存使用效率就非常低,不得不走swap。但如果使用了惰性计算,就完全不必去走swap来解决内存不够的问题。


相比于内存的使用效率,以及由于过大内存导致的swap所消耗的时间,这两者的性能损耗跟单纯一个if判断相比,实在是微不足道。


脱离剂量谈毒性是不对的,再退一步说,单纯多的一步if判断消耗的时间是纳秒级别,而且差不多只是两位数的纳秒,微乎其微。但是因此带来内存使用效率的提高,却是非常显著的,因此从性能角度来说,这个权衡应该更加偏向使用惰性求值才对。




结论


所以结论已经很明显了,六个理由对于getter来说其实根本站不住脚,而且使用getter的好处一方面带来了文件中更清晰的代码分布,另一方面提高了内存的使用效率。这也是为什么我推荐使用getter的原因。更加具体的原因我在这篇文章里也已经说清楚了,没看过的同学可以过去看一下。




去model化和数据对象




简述



去model化这个说法其实有点儿难听,model化就是使用数据对象,去model化就是不使用数据对象。所以这篇文章主要讨论的问题就是:数据传递时,是否要采用数据对象?这里的数据传递并不是说类似RPC的场景,而是在单个工程内部,各对象之间、各组件之间、各层之间的数据传递。


所谓数据对象,就是把不同类型的数据映射到不同类型的对象上,这个对象仅用于表达数据,数据通过对象的property来体现。瘦Model、贫血模型就属于这一类。


去Model化,就是不使用特定对象迎合特定数据的映射的方式,来表达数据。比如我们可以使用NSDictionary,或者其他手段例如reformer、virtual record,来避免这种数据映射对象。


关于这个问题的讨论涉及以下内容:


  • 如何理解面向对象思想
  • 为什么不使用数据对象
  • 去Model化都有哪些手段


通过以上三点,我希望能够帮助大家建立对面向对象的正确理解,让大家明白如何权衡是否要采用对象化的设计。以及最终当你决定不采用对象化思想而采用非对象化思想时,应该如何进行架构设计。




如何理解面向对象思想



面向对象的思想简单总结一下就是:将一个或多个复杂功能封装成为一个聚合体,这个聚合体经过抽象后,仅暴露少部分方法,这些方法向外部获取实现功能所需要的条件后,就能完成对应功能。传统的面向过程只针对功能的实现做了封装,也就是函数。经过这层封装后,仅暴露参数列表和函数名,用于外部调用者调用并完成功能。


我们可以推导出:函数封装了实现功能所需要的代码,因此对象实质上就是再次对函数进行了封装。将函数集合在一起,形成一个函数集合,面向对象思想的提出者把这个函数集合称之为对象,把对象的概念从理论映射到实际的工程领域,我们也可以叫它


然而我们很快就能发觉,只是单纯地把函数集合在一起是不够的,这些函数集有可能互相之间需要共用参数或共享状态。因此面向对象的理论设计者让对象自己也能够提供属性(property),来满足函数集间共用参数和共享状态的需求。这个函数集现在有了更贴切的说法:领域。因此当这个领域中的个别函数不需要共用参数或共享状态,仅仅是提供功能时,这些相关函数就可以体现为类方法。当领域里的函数需要共用参数或共享状态时,这些函数的体现就是实例方法。


这里补充一下,领域的概念我们更多会把它理解得比较大,比如多个相关对象形成一个领域。但一个对象自身所包含的所有函数也是一个领域,是大领域里的一个子领域。


以上就是一个对面向对象思想的朴素理解。在这个理解的基础上,还衍生出了非常多的概念,不过这并不在本文的讨论范围中。


总之,一个对象事实上是对一个较小领域的一种封装。对应到本文要讨论的问题来看,如果拿一个对象去表达一套数据而非一个领域,这在一定程度上是违背面向对象的设计初衷的。你看着好像是把数据对象化了,就是面向对象编程了,然而事实上并非如此。Martin Fowler早年也在他的《Anemic Domain Model》中提出了一样的看法:


The fundamental horror of this anti-pattern is that it's so contrary to the basic idea of object-oriented design; which is to combine data and process together. The anemic domain model is really just a procedural style design, exactly the kind of thing that object bigots like me (and Eric) have been fighting since our early days in Smalltalk. What's worse, many people think that anemic objects are real objects, and thus completely miss the point of what object-oriented design is all about.




为什么不使用数据对象



根据上一小节的内容,我们可以把对象抽象地理解为一个函数集,一个领域。在这一节里,我们再进一步推导:如果这些函数集里的所有函数,并不都是处在同一个问题领域内,那么面向对象的这种实践是否依旧成立?

答案是成立的,但显然我们并不建议这么做。不同领域的函数集如果被封装在了一起,在实际工程中,这种做法至少会带来以下问题:


  1. 当需要维护某个问题领域内的函数时,如果修改到某个需要被共用的参数或者需要被共享的对象,那么其他问题领域也存在被影响的可能。牵一发而动全身,这是我们不希望看到的。
  2. 当需要解决某个问题时,如果引入了某个充满了各种不同问题领域的函数集,这实质就是引入了对不同问题领域解决方案的依赖。当需要做代码迁移或复用时,就也要把不相关的解决方案一并引入。拔出萝卜带出泥,这也是我们不希望看到的。



当需要维护某个问题领域内的函数时,如果修改到某个需要被共用的参数或者需要被共享的对象,那么其他问题领域也存在被影响的可能。牵一发而动全身,这是我们不希望看到的。


我们在进行对象化设计时,必须要分割好问题域,才能保证设计出良好的架构。


业界所谓的各种XX建模、XX驱动设计、XXP,大部分其实都是在强调合理分割这一点,他们提供不同的方法论去告诉你应该如何去做分割的事情,以及如何把分割出来的部分再进一步做封装。然而这些XX概念要成立的话,就都一定需要具备这样一个前提条件:一个被封装出来的对象的活动领域,必须要小于等于当前被分割出来的子问题领域。如果不符合这个前提条件的话,一个大的问题领域即使被强行分割成各种小的问题领域,这些小的问题领域还是依旧难以被封装成为对象,因为对象的跨领域活动势必就要引入其它领域的问题解决方案,这就使得分割名不副实。


然而,一个被封装出来的对象的活动领域,必须要小于等于当前被分割出来的子问题领域这个前提在实际业务场景实践中,是否一定成立呢?如果一定成立的话,那么这种做法和这些方法论就是没问题的。如果在某些场景中不成立,对象化设计在这些场景就有问题了。


事实上,这个前提在实际业务场景中,是不一定成立的。在实际业务场景中,一个数据对象被多个业务领域使用是非常常见的。一个数据对象在不同层、不同模块中被使用也是非常常见的。所以,如果两个业务对象之间需要传递的仅是数据,在这个场景下就不适合传递对象化的数据。



当需要解决某个问题时,如果引入了某个充满了各种不同问题领域的函数集,这实质就是引入了对不同问题领域解决方案的依赖。当需要做代码迁移或复用时,就也要把不相关的解决方案一并引入。拔出萝卜带出泥,这也是我们不希望看到的。


这种场景其实就很好理解了。实际工程中,对象化数据往往不是一个独立存在的对象,而是依附于某一个领域。例如持久层提供的对象化数据,往往依附于持久层。网络层提供的对象化数据往往依附于网络层。当你的业务层某个模块使用来自这些层的对象化数据时,将来要迁移这个模块,就必须不得不把持久层或者网络层也跟着迁移过去。迁移发生的场景之一就是大型工程的组件化拆分,实施组件化时遇到这种问题是一件非常伤脑筋的事情。



小结


所以,在数据传递时,我不建议采用对象化设计,尤其是数据传递的两个实体是跨层实体或者跨模块实体时,对象化设计对架构的伤害非常大。


从实际而非理论的角度上讲,数据对象的使用主要存在这些问题:


  1. 数据对象并不符合面向对象的设计初衷
  2. 数据对象有变为支持多领域对象的可能
  3. 数据对象使得领域间依赖性变强




去Model化都有哪些手段



字典流


这种做法是最原始最简单的做法,我就不多说了。



reformer


reformer是这样的工作原理:


                        ------------------      ------------------
                        |                |      |                |
                       .|   Reformer_A   | .... |     View_A     |
                      . |                |      |                |
                     .  ------------------      ------------------
                    .
------------------ .    ------------------      ------------------
|                |.     |                |      |                |
|   APIManager   |......|   Reformer_B   | .... |     View_B     |
|                |.     |                |      |                |
------------------ .    ------------------      ------------------
                    .
                     .  ------------------      ------------------
                      . |                |      |                |
                       .|   Reformer_C   | .... |     View_C     |
                        |                |      |                |
                        ------------------      ------------------


APIManager提供了来自网络层的数据。Reformer_A,Reformer_B,Reformer_C,分别代表不同的领域。View_A,View_B,View_C,分别就是各领域对不同的数据应用之后产生的结果。在讲网络层的文章中,我设计了reformer的方式来实现非对象化。更详细的讲述和实际的Demo文章里都有,我在这里就不多说了。



Virtual Record


Virtual Record事实上把reformer和某个领域相关对象集合在了一起。Virtual Record和reformer的区别在于:reformer更加有利于单数据对应多对象的场景,Virtual Record更加有利于多数据对单对象的场景


------------------      ------------------
|                |      |                |
|  DataCenter_A  | .....| VirtualRecordA |.
|                |      |                | .
------------------      ------------------  .
                                             .   
------------------      ------------------    .  ------------------
|                |      |                |     . |                |
|  DataCenter_B  |......| VirtualRecordB |.......|     View_B     |
|                |      |                |     . |                |
------------------      ------------------    .  ------------------
                                             . 
------------------      ------------------  .
|                |      |                | .
|  DataCenter_C  | .....| VirtualRecordC |.
|                |      |                |
------------------      ------------------


事实上这幅图有个地方画的不太贴切,Virtual Record其实只是View_B的一个protocol,它并不是一个实例,所以才Virtual。关于Virtual Record的详细解释和案例,在讲持久层的文章里有。




总结


将数据对象化事实上是一个不符合面向对象思想的做法。


这种说法看起来很反直觉,但事实上如果你对面向对象有深入的理解,就能够明白其中的原因。这种不符合面向对象思想的做法,也导致了工程实践上代码的高耦合和组件难以复用的情况,这都是我们不希望看到的。我在这篇文章里提供了几种去Model化的做法,但看起来这应该不是所有的手段,很有可能还有其它方法。未来如果我遇到了其他场景想到了其它方法的话,会对它进行补充。如果各位读者还有不同的方法或其它的问题,也欢迎在评论区一起交流。




iOS应用架构谈 组件化方案




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应用架构谈 本地持久化方案及动态部署




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上我是不回答问题的哈。




怎么面试架构师




其实本文想说的是:当面试一个架构师的时候,我们应该问什么问题?我觉得,问什么样的问题,体现了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应用架构谈 网络层设计方案




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层的组织和调用方案




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应用架构谈 开篇




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上我是不回答问题的哈。








啰嗦一下


模块化开发不光要求代码级的模块化,比如区分各种功能,然后把功能的实现分散在各个对象或文件。大部分情况也要求部署级的模块化,使之能够通过使用库的方式模块化加载,模块化部署。在这一波"模块化"大潮中,各种不同类型的库被广泛使用着。如果你希望你的程序能够支持插件功能,其本质也依旧是通过库来实现。

其实库的本质是一堆函数实现的集合,他们被编译在某一个文件里,然后根据使用方式的不同,分为静态库动态库两类。在动态库里,又分为动态链接库动态加载库




静态库 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,这篇文章的文末提供了一些其他文章,那里有很多更深的内容,推荐大家去看一下。





单元测试框架:greatest




简述



关于C下的单元测试框架有许多,而且大多数比较复杂。我在写CTMemory的时候比较了好多框架,最终还是选择了greatest。原因如下:


  1. 非常轻量,引入一个头文件即可。
  2. 功能相对完善,支持测试用例用套件的方式进行组织,生成的测试程序可带各种参数进行配置。
  3. 使用方便,没多少函数,看一下样例一会儿就明白了。




开始



https://github.com/silentbicycle/greatest把代码拉下来就好。



先写一个makefile,方便跑测试


用greatest做单元测试,本质上就是使用greatest提供的宏来写一个程序,这个程序去跑一个又一个的TEST函数。所以在写这个程序之前,我们最好先写一个makefile方便后面的工作。


  • test.run就是编译生成的程序。
  • test_main.c就是你的测试程序的main函数写的地方,同时也负责组织test suit
  • test_cases.c就是你写test case的地方。
  • fileToTest.c就是你要测试的模块。


关于如何写makefile,可以看这里


CFLAGS += -Wall -Werror -Wextra -pedantic
CFLAGS += -Wmissing-prototypes
CFLAGS += -Wstrict-prototypes
CFLAGS += -Wmissing-declarations

CC = clang

HEADER_PATH = -I./greatest/

LINKED_OBJECTS = test_cases.o fileToTest.o

all: clean test.run

test: clean test.run
    ./test.run -v | ./greatest/greenest  # greatest提供了greenest这个程序来把通过的标绿,不通过的标红。

test.run: test_main.c ${LINKED_OBJECTS}
    ${CC} ${HEADER_PATH} ${CFLAGS} ${LDFLAGS} -g -o $@ test_main.c ${LINKED_OBJECTS}

clean:
    rm -f *.o *.core *.out *.run

test_cases.o:
    ${CC} ${HEADER_PATH} ${CFLAGS} ${LDFLAGS} -g -o $@ -c test_cases.c

fileToTest.o:
    ${CC} ${HEADER_PATH} ${CFLAGS} ${LDFLAGS} -pthread -g -o $@ -c ../fileToTest.c


这样写好以后每次make test就好了。



写test_main.c


这时候先不着急去写具体的实现代码,我们测试先行。文件名不重要,我喜欢叫test_main.c,你可以按照你自己的来。

在greatest框架中,有SUITE的概念,一个SUITE里面有很多个TEST。假设我们要测试A模块,那么就设立一个A SUIT,然后这个A SUIT里面都是关于A模块的TEST。对于test_main.c来说,只要组织好SUIT就好了,具体各自模块的TEST由各自的SUIT来组织。这样的管理方式非常棒,多人写测试代码的时候,各自管好各自的SUIT就好了。我的习惯是一个SUIT对应一个C文件,这样找代码的时候也比较好找。


下面的代码是test_main.c的:

  #include <stdlib.h>
  #include <stdio.h>
  #include <assert.h>

  #include "greatest.h"

  extern SUITE(test_cases);  /* test_cases这个SUIT是在test_cases.c里面的,在makefile里面已经链接进去了 */
  extern SUITE(test_cases2);

  GREATEST_MAIN_DEFS(); /* 这句必须要有,这个宏里面有greatest相关函数的定义 */

  int main(int argc, char **argv)
  {
      GREATEST_MAIN_BEGIN();

      RUN_SUITE(test_cases);
      RUN_SUITE(test_cases2);

      GREATEST_MAIN_END();

      return 0;
  }



写test_cases.c


test_cases.c其实就是一个SUIT,这个SUIT里面有很多TEST。我们直接看代码:


#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

#include "greatest.h"
#include "fileToTest.h"

SUITE(test_cases);  /* 在这里声明一下SUITE */

TEST test_operatin1(void) /* 每一个test case 的函数类型都用TEST来声明 */
{
    ASSERT(true);
    PASS();
}

TEST test_operatin2(void)
{
    ASSERT(true);
    PASS();
}

TEST test_operatin3(void)
{
    ASSERT(true);
    PASS();
}

SUITE(test_cases) {
    RUN_TEST(test_operatin1); /* 这里决定了都要跑哪些TEST */
    RUN_TEST(test_operatin2);
    RUN_TEST(test_operatin3);
}




结束

greatest是一个很小很方便的单元测试框架,但目前它还不支持多线程环境下的单元测试。一般我们在写自己的开源项目的时候,没有QA工程师来给你人肉测,只能靠我们自己去写测试用例了,greatest最适合的就是这样的场景。




2015-5-12 补:关于多线程下单元测试的处理

今天早上收到了greatest作者的邮件,他说在多线程的情况下,可以使用他写的另一套工具https://github.com/silentbicycle/autoclave来做测试。

他还补充了一点:greatest设计的初衷是单独测试程序的某一个部分,这时候默认是不考虑多线程的不确定性的。然而autoclave会一直跑你的程序,直到出现问题,才会停,这时候比如当程序出现死锁,它就能够帮你把debugger加载进去。

最后附上邮件原文:
mail




pthread的各种同步机制




简述


pthread是POSIX标准的多线程库,UNIX、Linux上广泛使用,windows上也有对应的实现,所有的函数都是pthread打头,也就一百多个函数,不是很复杂。然而多线程编程被普遍认为复杂,主要是因为多线程给程序引入了一定的不可预知性,要控制这些不可预知性,就需要使用各种锁各种同步机制,不同的情况就应该使用不同的锁不同的机制。什么事情一旦放到多线程环境,要考虑的问题立刻就上升了好几个量级。多线程编程就像潘多拉魔盒,带来的好处不可胜数,然而工程师只要一不小心,就很容易让你的程序失去控制,所以你得用各种锁各种机制管住它。要解决好这些问题,工程师们就要充分了解这些锁机制,分析不同的场景,选择合适的解决方案。

处理方案不对的话,那能不能正确跑完程序就只好看运气啦~

2015.2.10修正:

感谢评论区张杨同学的指正,行文时我这边也犯了很多错误。同时也欢迎其他同学在评论区讨论。:D




预备


  1. 阅读这篇文章之前你最好有一些实际的pthread使用经验,因为这篇文章不是写给从零开始学习pthread的人的。
  2. 想要5分钟内立刻搞定多线程同步机制的人,觉得文章太长不看的人,这篇文章就不是写给你们看的。Mac请点左上角关闭,KDE和GNOME请点右上角关闭。
  3. 如果你经常困惑于各种锁和同步机制的方案,或者你想寻找比现有代码更优雅的方案来处理你遇到的多线程问题,那这篇文章就是写给你的。
  4. 如果你发现别人的多线程代码写得不对,但是勉强能跑,然后你找到他让他改的时候,跟他解释半天也不愿意改,那这篇文章就是写给他们的。




开始。




Mutex Lock 互斥锁


MUTual-EXclude Lock,互斥锁。 它是理解最容易,使用最广泛的一种同步机制。顾名思义,被这个锁保护的临界区就只允许一个线程进入,其它线程如果没有获得锁权限,那就只能在外面等着。

它使用得非常广泛,以至于大多数人谈到锁就是mutex。mutex是互斥锁,pthread里面还有很多锁,mutex只是其中一种。


相关宏和函数

    PTHREAD_MUTEX_INITIALIZER // 用于静态的mutex的初始化,采用默认的attr。比如: static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

    int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); // 用于动态的初始化

    int pthread_mutex_destroy(pthread_mutex_t *mutex); // mutex锁干掉,并且释放所有它所占有的资源

    int pthread_mutex_lock(pthread_mutex_t *mutex); // 请求锁,如果当前mutex已经被锁,那么这个线程就会卡在这儿,直到mutex被释放

    int pthread_mutex_unlock(pthread_mutex_t *mutex); // 解锁

    int pthread_mutex_trylock(pthread_mutex_t *mutex); // 尝试请求锁,如果当前mutex已经被锁或者不可用,这个函数就直接return了,不会把线程卡住



要注意的地方


关于mutex的初始化


mutex的初始化分两种,一种是用宏(PTHREAD_MUTEX_INITIALIZER),一种是用函数(pthread_mutex_init)。 如果没有特殊的配置要求的话,使用宏比较好,因为它比较快。只有真的需要配置的时候,才需要用函数。也就是说,凡是pthread_mutex_init(&mutex, NULL)的地方都可以使用PTHREAD_MUTEX_INITIALIZER,因为在pthread_mutex_init这个函数里的实现其实也是用了PTHREAD_MUTEX_INITIALIZER


    ///////////////////// pthread_src/include/pthread/pthread.h

    #define PTHREAD_MUTEX_INITIALIZER __PTHREAD_MUTEX_INITIALIZER


    ///////////////////// pthread_src/sysdeps/generic/bits/mutex.h

    #  define __PTHREAD_MUTEX_INITIALIZER \
        { __PTHREAD_SPIN_LOCK_INITIALIZER, __PTHREAD_SPIN_LOCK_INITIALIZER, 0, 0, 0, 0, 0, 0 } // mutex锁本质上是一个spin lock,空转锁,关于空转锁的东西在下面会提到。


    ///////////////////// pthread_src/sysdeps/generic/pt-mutex-init.c
    int
    _pthread_mutex_init (pthread_mutex_t *mutex,
                 const pthread_mutexattr_t *attr)
    {
      *mutex = (pthread_mutex_t) __PTHREAD_MUTEX_INITIALIZER; // 你看,这里其实用的也是宏。就这一句是初始化,下面都是在设置属性。

      if (! attr
          || memcmp (attr, &__pthread_default_mutexattr, sizeof (*attr) == 0))
        /* The default attributes.  */
        return 0;

      if (! mutex->attr
          || mutex->attr == __PTHREAD_ERRORCHECK_MUTEXATTR
          || mutex->attr == __PTHREAD_RECURSIVE_MUTEXATTR)
        mutex->attr = malloc (sizeof *attr);                //pthread_mutex_destroy释放的就是这里的资源

      if (! mutex->attr)
        return ENOMEM;

      *mutex->attr = *attr;
      return 0;
    }

但是业界有另一种说法是:早年的POSIX只支持在static变量上使用PTHREAD_MUTEX_INITIALIZER,所以PTHREAD_MUTEX_INITIALIZER尽量不要到处都用,所以使用的时候你得搞清楚你的pthread的实现版本是不是比较老的。



mutex锁不是万能灵药


基本上所有的问题都可以用互斥的方案去解决,大不了就是慢点儿,但不要不管什么情况都用互斥,都能采用这种方案不代表都适合采用这种方案。而且这里所说的慢不是说mutex的实现方案比较慢,而是互斥方案影响的面比较大,本来不需要通过互斥就能让线程进入临界区,但用了互斥方案之后,就使这样的线程不得不等待互斥锁的释放,所以就慢了。甚至有些场合用互斥就很蛋疼,比如多资源分配,线程步调通知等。 如果是读多写少的场合,就比较适合读写锁(reader/writter lock),如果临界区比较短,就适合空转锁(pin lock)...这些我在后面都会说的,你可以翻到下面去看。

提到这个的原因是:大多数人学pthread学到mutex就结束了,然后不管什么都用mutex。那是不对的!!!



预防死锁


如果要进入一段临界区需要多个mutex锁,那么就很容易导致死锁,单个mutex锁是不会引发死锁的。要解决这个问题也很简单,只要申请锁的时候按照固定顺序,或者及时释放不需要的mutex锁就可以。这就对我们的代码有一定的要求,尤其是全局mutex锁的时候,更需要遵守一个约定。

如果是全局mutex锁,我习惯将它们写在同一个头文件里。一个模块的文件再多,都必须要有两个umbrella header file。一个是整个模块的伞,外界使用你的模块的时候,只要include这个头文件即可。另一个用于给模块的所有子模块去include,然后这个头文件里面就放一些公用的宏啊,配置啊啥的,全局mutex放在这里就最合适了。这两个文件不能是同一个,否则容易出循环include的问题。如果有人写模块不喜欢写这样的头文件的,那现在就要改了。

然后我的mutex锁的命名规则就是:作用_mutex_序号,比如LinkListMutex_mutex_1,OperationQueue_mutex_2,后面的序号在每次有新锁的时候,就都加一个1。如果有哪个临界区进入的时候需要获得多个mutex锁的,我就按照序号的顺序去进行加锁操作(pthread_mutex_lock),这样就能够保证不会出现死锁了。



如果是属于某个struct内部的mutex锁,那么也一样,只不过序号可以不必跟全局锁挂钩,也可以从1开始数。



还有另一种方案也非常有效,就是用pthread_mutex_trylock函数来申请加锁,这个函数在mutex锁不可用时,不像pthread_mutex_lock那样会等待。pthread_mutex_trylock在申请加锁失败时立刻就会返回错误:EBUSY(锁尚未解除)或者EINVAL(锁变量不可用)。一旦在trylock的时候有错误返回,那就把前面已经拿到的锁全部释放,然后过一段时间再来一遍。 当然也可以使用pthread_mutex_timedlock这个函数来申请加锁,这个函数跟pthread_mutex_trylock类似,不同的是,你可以传入一个时间参数,在申请加锁失败之后会阻塞一段时间等解锁,超时之后才返回错误。



这两种方案我更多会使用第一种,原因如下:

  • 一般情况下进入临界区需要加的锁数量不会太多,第一种方案能够hold住。如果多于2个,你就要考虑一下是否有些锁是可以合并的了。

第一种方案适合锁比较少的情况,因为这不会导致非常大的阻塞延时。但是当你要加的锁非常多,ABCDE,你加到D的时候阻塞了,然而其他线程可能只需要AB就可以运行,就也会因为AB已经被锁住而阻塞,这时候才会采用第二种方案。如果要加的锁本身就不多,只有AB两个,那么阻塞一下也还可以。

  • 第二种方案在面临阻塞的时候,要操作的事情太多。

当你把所有的锁都释放以后,你的当前线程的处理策略就会导致你的代码复杂度上升:当前线程总不能就此退出吧,你得找个地方把它放起来,让它去等待一段时间之后再去申请锁,如果有多个线程出现了这样的情况,你就需要一个线程池来存放这些等待解锁的线程。如果临界区是嵌套的,你在把这个线程挂起的时候,最好还要把外面的锁也释放掉,要不然也会容易导致死锁,这就需要你在一个地方记录当前线程使用锁的情况。这里要做的事情太多,复杂度比较大,容易出错。



所以总而言之,设计的时候尽量减少同一临界区所需要mutex锁的数量,然后采用第一种方案。如果确实有需求导致那么多mutex锁,那么就只能采用第二种方案了,然后老老实实写好周边代码。但是!umbrella header file和按照序号命名mutex锁是个非常好的习惯,可以允许你随着软件的发展而灵活采取第一第二种方案。

但是到了semaphore情况下的死锁处理方案时,上面两种方案就都不顶用了,后面我会说。另外,还有一种死锁是自己把自己锁死了,这个我在后面也会说。




Reader-Writter Lock 读写锁


前面mutex锁有个缺点,就是只要锁住了,不管其他线程要干什么,都不允许进入临界区。设想这样一种情况:临界区foo变量在被bar1线程读着,加了个mutex锁,bar2线程如果也要读foo变量,因为被bar1加了个互斥锁,那就不能读了。但事实情况是,读取数据不影响数据内容本身,所以即便被1个线程读着,另外一个线程也应该允许他去读。除非另外一个线程是写操作,为了避免数据不一致的问题,写线程就需要等读线程都结束了再写。

因此诞生了Reader-Writter Lock,有的地方也叫Shared-Exclusive Lock,共享锁。

Reader-Writter Lock的特性是这样的,当一个线程加了读锁访问临界区,另外一个线程也想访问临界区读取数据的时候,也可以加一个读锁,这样另外一个线程就能够成功进入临界区进行读操作了。此时读锁线程有两个。当第三个线程需要进行写操作时,它需要加一个写锁,这个写锁只有在读锁的拥有者为0时才有效。也就是等前两个读线程都释放读锁之后,第三个线程就能进去写了。总结一下就是,读写锁里,读锁能允许多个线程同时去读,但是写锁在同一时刻只允许一个线程去写。

这样更精细的控制,就能减少mutex导致的阻塞延迟时间。虽然用mutex也能起作用,但这种场合,明显读写锁更好嘛!



相关宏和函数


    PTHREAD_RWLOCK_INITIALIZER

    int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
    int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

    int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
    int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

    int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
    int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

    int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

    int pthread_rwlock_timedrdlock_np(pthread_rwlock_t *rwlock, const struct timespec *deltatime); // 这个函数在Linux和Mac的man文档里都没有,新版的pthread.h里面也没有,旧版的能找到
    int pthread_rwlock_timedwrlock_np(pthread_rwlock_t *rwlock, const struct timespec *deltatime); // 同上



要注意的地方


命名


跟上面提到的写muetx锁的约定一样,操作,类别,序号最好都要有。比如OperationQueue_rwlock_1


认真区分使用场合,记得避免写线程饥饿


由于读写锁的性质,在默认情况下是很容易出现写线程饥饿的。因为它必须要等到所有读锁都释放之后,才能成功申请写锁。不过不同系统的实现版本对写线程的优先级实现不同。Solaris下面就是写线程优先,其他系统默认读线程优先。

比如在写线程阻塞的时候,有很多读线程是可以一个接一个地在那儿插队的(在默认情况下,只要有读锁在,写锁就无法申请,然而读锁可以一直申请成功,就导致所谓的插队现象),那么写线程就不知道什么时候才能申请成功写锁了,然后它就饿死了。

为了控制写线程饥饿,必须要在创建读写锁的时候设置PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE,不要用PTHREAD_RWLOCK_PREFER_WRITER_NP啊,这个似乎没什么用,感觉应该是个bug,不要问我是怎么知道的。。。


////////////////////////////// /usr/include/pthread.h

/* Read-write lock types.  */
#if defined __USE_UNIX98 || defined __USE_XOPEN2K
enum
{
  PTHREAD_RWLOCK_PREFER_READER_NP,
  PTHREAD_RWLOCK_PREFER_WRITER_NP, // 妈蛋,没用,一样reader优先
  PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP,
  PTHREAD_RWLOCK_DEFAULT_NP = PTHREAD_RWLOCK_PREFER_READER_NP
};

总的来说,这样的锁建立之后一定要设置优先级,不然就容易出现写线程饥饿。而且读写锁适合读多写少的情况,如果读、写一样多,那这时候还是用mutex锁比较合理。




spin lock 空转锁


上面在给出mutex锁的实现代码的时候提到了这个spin lock,空转锁。它是互斥锁、读写锁的基础。在其它同步机制里condition variable、barrier等都有它的身影。

我先说一下其他锁申请加锁的过程,你就知道什么是spin lock了。

互斥锁和读写锁在申请加锁的时候,会使得线程阻塞,阻塞的过程又分两个阶段,第一阶段是会先空转,可以理解成跑一个while循环,不断地去申请锁,在空转一定时间之后,线程会进入waiting状态(对的,跟进程一样,线程也分很多状态),此时线程就不占用CPU资源了,等锁可用的时候,这个线程会被唤醒。

为什么会有这两个阶段呢?主要还是出于效率因素。


  • 如果单纯在申请锁失败之后,立刻将线程状态挂起,会带来context切换的开销,但挂起之后就可以不占用CPU资源了,原属于这个线程的CPU时间就可以拿去做更加有意义的事情。假设锁在第一次申请失败之后就又可用了,那么短时间内进行context切换的开销就显得很没效率。

  • 如果单纯在申请锁失败之后,不断轮询申请加锁,那么可以在第一时间申请加锁成功,同时避免了context切换的开销,但是浪费了宝贵的CPU时间。假设锁在第一次申请失败之后,很久很久才能可用,那么CPU在这么长时间里都被这个线程拿来轮询了,也显得很没效率。


于是就出现了两种方案结合的情况:在第一次申请加锁失败的时候,先不着急切换context,空转一段时间。如果锁在短时间内又可用了,那么就避免了context切换的开销,CPU浪费的时间也不多。空转一段时间之后发现还是不能申请加锁成功,那么就有很大概率在将来的不短的一段时间里面加锁也不成功,那么就把线程挂起,把轮询用的CPU时间释放出来给别的地方用。

所以spin lock就是这样的一个锁:它在第一次申请加锁失败的时候,会不断轮询,直到申请加锁成功为止,期间不会进行线程context的切换。《APUE》中原文是这样:A spin lock is like a mutex, except that instead of blocking a process by sleeping, the process is blocked by busy-waiting (spinning) until the lock can be acquired. 互斥锁和读写锁基于spin lock又多做了超时检查和切换context的操作,如此而已。事实上,spin lock在实现的时候,有一个__pthread_spin_count限制,如果空转次数超过这个限制,线程依旧会挂起(__shed_yield)。


这里是spin lock申请加锁的实现:


/////////////////////////////pthread_src/sysdeps/posix/pt-spin.c

/* Lock the spin lock object LOCK.  If the lock is held by another
    thread spin until it becomes available.  */
int
_pthread_spin_lock (__pthread_spinlock_t *lock)
{
  int i;

  while (1)
    {
      for (i = 0; i < __pthread_spin_count; i++)
    {
      if (__pthread_spin_trylock (lock) == 0)
        return 0;
    }

      __sched_yield ();
    }
}



相关宏和函数


我没在man里面找到spin lock相关的函数,但事实上外面还是能够使用的,下面是我在源代码里面挖到的原型:


////////////////////////////////pthread_src/pthread/pt-spin-inlines.c

/* Weak aliases for the spin lock functions.  Note that
   pthread_spin_lock is left out deliberately.  We already provide an
   implementation for it in pt-spin.c.  */
weak_alias (__pthread_spin_destroy, pthread_spin_destroy);
weak_alias (__pthread_spin_init, pthread_spin_init);
weak_alias (__pthread_spin_trylock, pthread_spin_trylock);
weak_alias (__pthread_spin_unlock, pthread_spin_unlock);

/////////////////////////////////pthread_src/sysdeps/posix/pt-spin.c

weak_alias (_pthread_spin_lock, pthread_spin_lock);

/*-------------------------------------------------*/

    PTHREAD_SPINLOCK_INITIALIZER
    int pthread_spin_init (__pthread_spinlock_t *__lock, int __pshared);
    int pthread_spin_destroy (__pthread_spinlock_t *__lock);
    int pthread_spin_trylock (__pthread_spinlock_t *__lock);
    int pthread_spin_unlock (__pthread_spinlock_t *__lock);
    int pthread_spin_lock (__pthread_spinlock_t *__lock);

/*-------------------------------------------------*/



注意事项



还是要分清楚使用场合


了解了空转锁的特性,我们就发现这个锁其实非常适合临界区非常短的场合,或者实时性要求比较高的场合。

由于临界区短,线程需要等待的时间也短,即便轮询浪费CPU资源,也浪费不了多少,还省了context切换的开销。 由于实时性要求比较高,来不及等待context切换的时间,那就只能浪费CPU资源在那儿轮询了。

不过说实话,大部分情况你都不会直接用到空转锁,其他锁在申请不到加锁时也是会空转一定时间的,如果连这段时间都无法满足你的请求,那要么就是你扔的线程太多,或者你的临界区没你想象的那么短。




pthread_cleanup_push() & pthread_cleanup_pop()


线程是允许在退出的时候,调用一些回调方法的。如果你需要做类似的事情,那么就用以下这两种方法:


    void pthread_cleanup_push(void (*callback)(void *), void *arg);
    void pthread_cleanup_pop(int execute);

正如名字所暗示的,它背后有一个stack,你可以塞很多个callback函数进去,然后调用的时候按照先入后出的顺序调用这些callback。所以你在塞callback的时候,如果是关心调用顺序的,那就得注意这一点了。

但是!你塞进去的callback只有在以下情况下才会被调用:


  1. 线程通过pthread_exit()函数退出
  2. 线程被pthread_cancel()取消
  3. pthread_cleanup_pop(int execute)时,execute传了一个非0值


也就是说,如果你的线程函数是这么写的,那在线程结束的时候就不会调到你塞进去的那些callback了:


static void * thread_function(void *args)
{
    ...
    ...
    ...
    ...
    return 0; // 线程退出时没有调用pthread_exit()退出,而是直接return,此时是不会调用栈内callback的
}


exit()行不行?尼玛一调用这个整个进程就挂掉了~只要在任意线程调用exit(),整个进程就结束了,不要瞎搞。 pthread_cleanup_push塞入的callback可以用来记录线程结束的点,活着打打日志啥的,一般不太会在这里执行业务逻辑。在线程结束之后如果要执行业务逻辑,一般用下面提到的pthread_join



注意事项

callback函数是可以传参数的


对的,在pthread_cleanup_push函数中,第二个参数的值会作为callback函数的第一个参数,不要浪费了,拿来打打日志也不错。举个例子:


void callback(void *callback_arg)
{
    printf("arg is : %s\n", (char *)callback_arg);
}

static void * thread_function(void *thread_arg)
{
    ...
    pthread_cleanup_push(callback, "this is a queue thread, and was terminated.");
    ...
    pthread_exit((void *) 0); // 这句不调用,线程结束就不会调用你塞进去的callback函数。
    return ((void *) 0);
}

int main ()
{
    ...
    ...
    error = pthread_create(&tid, NULL, thread_function, (void *)thread_arg)
    ...
    ...
    return 0;
}


你也发现了,callback函数的参数是在线程函数里面设置的,所以拿来做业务也是可以的,不过一般都是拿来做清理的事情,很少会把它放到业务里面去做。



要保持callback栈平衡


有的时候我们并不一定要在线程结束的时候调用这些callback,那怎么办?直接return不就好了么,return的话,不就不调用callback了?

如果你真是这么想的,请去撞墙5分钟。

callback的调用栈一定要保持平衡,如果你不保持平衡就退出了线程,后面的结果是undefine的,有的系统就core dump了(比如Mac),有的系统还就这么跑过去了一点反应也没有(这个是我猜的,没验证过,因为callback栈不平衡的结果是未定义的)。

关于要在保持栈平衡的前提下,选择性地调用callback,似乎只能在中间调用*__handlers = __handler.next;这句话了?也许这是个伪需求,关于这个问题,大家也可以在评论区讨论一下。

以下是个使用样例:


void callback1(void *callback_arg)
{
    printf("arg is : %s\n", (char *)callback_arg);
}

void callback2(void *callback_arg)
{
    printf("arg is : %s\n", (char *)callback_arg);
}

static void * thread_function(void *thread_arg)
{
    ...

    pthread_cleanup_push(callback1, "this is callback 1.");
    pthread_cleanup_push(callback2, "this is callback 2.");

    ...

    if (thread_arg->should_callback) {
        pthread_exit((void *) result);
    }

    pthread_cleanup_pop(0); // 传递参数0,在pop的时候就不会调用对应的callback,如果传递非0值,pop的时候就会调用对应callback了。
    pthread_cleanup_pop(0); // push了两次就pop两次,你要是只pop一次就不能编译通过
    pthread_exit((void *) result);

    return ((void *) 0);
}

int main ()
{
    ...
    ...
    error = pthread_create(&tid, NULL, thread_function, (void *)thread_arg)
    ...
    ...
    return 0;
}

插播

此处张扬同学提到push和pop如果不一一对应,就会导致编译不过。事实的确如此,在pthread对于这两个函数是通过宏来实现的,如果没有一一对应,编译器就会报} missing的错误。其相关实现代码如下:

/* ./include/pthread/pthread.h */
#define pthread_cleanup_push(rt, rtarg) __pthread_cleanup_push(rt, rtarg)
#define pthread_cleanup_pop(execute) __pthread_cleanup_pop(execute)

/* ./sysdeps/generic/bits/cancelation.h */
#define __pthread_cleanup_push(rt, rtarg) \
    { \
      struct __pthread_cancelation_handler **__handlers \
        = __pthread_get_cleanup_stack (); \
      struct __pthread_cancelation_handler __handler = \
        { \
          (rt), \
          (rtarg), \
          *__handlers \
        }; \
      *__handlers = &__handler;

#define __pthread_cleanup_pop(execute) \
      if (execute) \
        __handler.handler (__handler.arg); \
       *__handlers = __handler.next; \
    } \

插播结束





pthread_join()


在线程结束的时候,我们能通过上面的pthread_cleanup_push塞入的callback方法知道,也能通过pthread_join这个方法知道。一般情况下,如果是出于业务的需要要知道线程何时结束的,都会采用pthread_join这个方法。


它适用这样的场景:

你有两个线程,B线程在做某些事情之前,必须要等待A线程把事情做完,然后才能接着做下去。这时候就可以用join。


原型:


    int pthread_join(pthread_t thread, void **value_ptr);


在B线程里调用这个方法,第一个参数传A线程的thread_id, 第二个参数你可以扔一个指针进去。当A线程调用pthread_exit(void *value_ptr)来结束的时候,A的value_ptr就会到pthread_joinvalue_ptr去,你可以理解成A把它计算出来的结果放到exit函数里面去,然后其他join的线程就能拿到这个数据了。

在B线程join了A线程之后,B线程会阻塞住,直到A线程跑完。A线程跑完之后,自动被detach,后续再要join的线程就会报EINVAL



注意事项


新创建的线程默认是join属性,每一个join属性的线程都需要通过pthread_join来回收资源


  • 如果A线程已经跑完,但没被join过,此时B线程要去join A线程的时候,pthread_join是会立刻正确返回的,之后A线程就被detach了,占用的资源也会被释放。
  • 如果A线程已经跑完,后面没人join它,它占用的资源就会一直在哪儿,变成僵尸线程。

所以要么在创建线程的时候就把线程设置为detach的线程,这样线程跑完以后不用join,占用的资源自动回收。

要么不要忘记去join一下,把资源回收了,不要留僵尸。


注意传递的参数的内存生命周期


虽然线程和进程共享同一个进程资源,但如果在pthread_exit()里面你传递的指针指向的是栈内存,那么在结束之后,这片内存还是会被回收的,具体到使用的时候,不同的系统又是不同的方案了。

还有就是,一定要在获得value_ptr之后,检查一下value_ptr是否PTHREAD_CANCELED,因为如果你要等待的线程被cancel掉了,你拿到的就是这个数据。


多个线程join同一个线程


pthread_join是允许多个线程等待同一个线程的结束的。如果要一个线程等待多个线程的结束,那就需要用下面提到的条件变量了,或者barrier也行。

但是多个线程join同一个线程的时候,情况就比较多。多而已,不复杂。我们先建立一个约定:A线程是要被join的线程,BCDEF是要等待A线程结束的线程。下面说一下每种情况:


  • A线程正在运行,BCDEF线程发起对A的join,发起join结束后,A仍然在运行中

此时BCDEF线程都会被阻塞,等待A线程的结束。A线程结束之后,BCDEF都被唤醒,能够正常获得A线程通过pthread_exit()返回的数据。


  • A线程正在运行,BCDEF发起对A的join,BCD发起join成功后,A线程结束,然后EF发起join

此时BCD线程能够正常被唤醒,并完成任务,由于被join后A线程被detach,资源释放,后续EF再要发起join,就会EINVAL


  • A线程正在运行,且运行结束。此时BCDEF发起对A的join。

此时谁先调用成功,谁就能完成任务,后续再要join的就都会EINVAL。一旦有一个线程join成功,A立刻被detach,资源释放,然后后面其他的线程就都不会join成功。



总的来说,只要线程运行结束,并且被detach了,后面再join就不行了,只要线程还在运行中,就能join。如果运行结束了,第一次被join之后,线程就被detach了,后续就不能join。当然了,如果线程本来就是detach属性的线程,那任何时候都无法被join。




Condition Variables 条件变量


pthread_join解决的是多个线程等待同一个线程的结束。条件变量能在合适的时候唤醒正在等待的线程。具体什么时候合适由你自己决定。它必须要跟互斥锁联合起来用。原因我会在注意事项里面讲。

场景:B线程和A线程之间有合作关系,当A线程完成某件事情之前,B线程会等待。当A线程完成某件事情之后,需要让B线程知道,然后B线程从等待状态中被唤醒,然后继续做自己要做的事情。

如果不用条件变量的话,也行。那就是搞个volatile变量,然后让其他线程不断轮询,一旦这个变量到了某个值,你就可以让线程继续了。如果有多个线程需要修改这个变量,那就再加个互斥锁或者读写锁。

但是!!!这做法太特么愚蠢了,还特别浪费CPU时间,所以还在用volatile变量标记线程状态的你们也真是够了!!!

大致的实现原理是:一个条件变量背后有一个池子,所有需要wait这个变量的线程都会进入这个池子。当有线程扔出这个条件变量的signal,系统就会把这个池子里面的线程挨个唤醒。



相关宏和函数


    PTHREAD_COND_INITIALIZER
    int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
    int pthread_cond_destroy(pthread_cond_t *cond);

    int pthread_cond_signal(pthread_cond_t *cond);
    int pthread_cond_broadcast(pthread_cond_t *cond);

    int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
    int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);


补充一下,原则上pthread_cond_signal是只通知一个线程,pthread_cond_broadcast是用于通知很多线程。但POSIX标准也允许让pthread_cond_signal用于通知多个线程,不强制要求只允许通知一个线程。具体看各系统的实现。一般我都是用pthread_cond_broadcast

另外,在调用pthread_cond_wait之前,必须要申请互斥锁,当线程通过pthread_cond_wait进入waiting状态时,会释放传入的互斥锁。

下面我先给一个条件变量的使用例子,然后再讲需要注意的点。


void thread_waiting_for_condition_signal ()
{
    pthread_mutex_lock(&mutex);
    while (operation_queue == NULL) {
        pthread_cond_wait(&condition_variable_signal, &mutex);
    }

    /*********************************/
    /* 做一些关于operation_queue的事 */
    /*********************************/

    pthread_mutex_unlock(&mutex);
}

void thread_prepare_queue ()
{
    pthread_mutex_lock(&mutex);

    /*********************************/
    /* 做一些关于operation_queue的事 */
    /*********************************/

    pthread_cond_signal(&condition_variable_signal); // 事情做完了之后要扔信号给等待的线程告诉他们做完了
    pthread_mutex_unlock(&mutex);


    /**************************/
    /* 这里可以做一些别的事情 */
    /**************************/

    ...

    pthread_exit((void *) 0);
}



要注意的地方


一定要跟mutex配合使用


    void thread_function_1 ()
    {
        done = 1;
        pthread_cond_signal(&condition_variable_signal);
    }

    void thread_function_2 ()
    {
        while (done == 0) {
            pthread_cond_wait(&condition_variable_signal, NULL);
        }
    }


这样行不行?当然不行。为什么不行?


这里涉及一个非常精巧的情况:在thread_function_2发现done=0的时候,准备要进行下一步的wait操作。在具体开始下一步的wait操作之前,thread_function_1一口气完成了设置done,发送信号的事情。嗯,thread_function_2还没来得及waiting呢,thread_function_1就把信号发出去了,也没人接收这信号,thread_function_2继续执行waiting之后,就只能等待多戈了。



一定要检测你要操作的内容


    void thread_function_1 ()
    {
        pthread_mutex_lock(&mutex);

        ...
        operation_queue = create_operation_queue();
        ...

        pthread_cond_signal(&condition_variable_signal);
        pthread_mutex_unlock(&mutex);
    }

    void thread_function_2 ()
    {
        pthread_mutex_lock(&mutex);
        ...
        pthread_cond_wait(&condition_variable_signal, &mutex);
        ...
        pthread_mutex_unlock(&mutex);
    }


这样行不行?当然不行。为什么不行?


比如thread_function_1一下子就跑完了,operation_queue也初始化好了,信号也扔出去了。这时候thread_function_2刚刚启动,由于它没有去先看一下operation_queue是否可用,直接就进入waiting状态。然而事实是operation_queue早已搞定,再也不会有人扔我已经搞定operation_queue啦的信号,thread_function_2也不知道operation_queue已经好了,就只能一直在那儿等待多戈了...



一定要用while来检测你要操作的内容而不是if


    void thread_function_1 ()
    {
        pthread_mutex_lock(&mutex);
        done = 1;
        pthread_cond_signal(&condition_variable_signal);
        pthread_mutex_unlock(&mutex);
    }

    void thread_function_2 ()
    {
        pthread_mutex_lock(&mutex);
        if (done == 0) {
            pthread_cond_wait(&condition_variable_signal, &mutex);
        }
        pthread_mutex_unlock(&mutex);
    }


这样行不行?大多数情况行,但是用while更加安全。


如果有别人写一个线程去把这个done搞成0了,期间没有申请mutex锁。

那么这时用if去判断的话,由于线程已经从wait状态唤醒,它会直接做下面的事情,而全然不知done的值已经变了。

如果这时用while去判断的话,在pthread_cond_wait解除wait状态之后,会再去while那边判断一次done的值,只有这次done的值对了,才不会进入wait。如果这期间done被别的不长眼的线程给改了,while补充的那一次判断就帮了你一把,能继续进入waiting。

不过这解决不了根本问题哈,如果那个不长眼的线程在while的第二次判断之后改了done,那还是要悲剧。根本方案还是要在改done的时候加mutex锁。

总而言之,用if也可以,毕竟不太容易出现不长眼的线程改done变量不申请加mutex锁的。用while的话就多了一次判断,安全了一点,即便有不长眼的线程干了这么龌龊的事情,也还能hold住。



扔信号的时候,在临界区里面扔,不要在临界区外扔


    void thread_function_1 ()
    {
        pthread_mutex_lock(&mutex);
        done = 1;
        pthread_mutex_unlock(&mutex);

        pthread_cond_signal(&condition_variable_signal);
    }

    void thread_function_2 ()
    {
        pthread_mutex_lock(&mutex);
        if (done == 0) {
            pthread_cond_wait(&condition_variable_signal, &mutex);
        }
        pthread_mutex_unlock(&mutex);
    }


这样行不行?当然不行。为什么不行?《Advanced Programming in the UNIX Enviroment 3 Edtion》这本书里也把扔信号的事儿放在临界区外面了呢。



插播:

(此处张扬同学指出《APUE》在这里有一段论述,在这里我把这段摘下来)


《APUE》中关于这个问题是这么描述的:


When we put a message on the work queue, we need to hold the mutex, but we don’t need to hold the mutex when we signal the waiting threads. As long as it is okay for a thread to pull the message off the queue before we call cond_signal, we can do this after releasing the mutex.


在临界区外扔signal这种做法需要满足一些前提,这种做法不属于一种普适做法。所以我认为在制定编程规范的时候,应该禁止这种做法。在这份资料的第5页也对这个问题有一段小的论证。它的建议也是ALWAYS HOLD THE LOCK WHILE SIGNALING

上面提到的资料的原文如下:

TIP: ALWAYS HOLD THE LOCK WHILE SIGNALING

Although it is strictly not necessary in all cases, it is likely simplest and
best to hold the lock while signaling when using condition variables. The
example above shows a case where you must hold the lock for correctness;
however, there are some other cases where it is likely OK not to, but
probably is something you should avoid. Thus, for simplicity, hold the
lock when calling signal.

The converse of this tip, i.e., hold the lock when calling wait, is not just
a tip, but rather mandated by the semantics of wait, because wait always
(a) assumes the lock is held when you call it, (b) releases said lock when
putting the caller to sleep, and (c) re-acquires the lock just before returning.
Thus, the generalization of this tip is correct: hold the lock when
calling signal or wait, and you will always be in good shape.

插播结束



不行就是不行,哪怕是圣经上这么写,也不行。哼。


就应该永远都在临界区里面扔条件信号,我画了一个高清图来解释这个事情,图比较大,可能要加载一段时间:

image

看到了吧,1的情况就是在临界区外扔信号的坏处。由于在临界区外,其他线程要申请加mutex锁是可以成功的,然后这个线程要是改了你的敏感数据,你就只能去哭了...




semaphore 信号量


pthread库里面藏了一个semaphore(感谢评论区张扬指正:semaphore是进程间PV,也属于posix标准的组成部分,故前面的说法并不准确。),man手册里面似乎也找不到semaphore相关的函数。

semaphore事实上就是我们学《操作系统》的时候所说的PV操作。 你也可以把它理解成带有数量控制的互斥锁,当sem_init(&sem, 0, 1);时,他就是一个mutex锁了。


场景:比如有3台打印机,有5个线程要使用打印机,那么semaphore就会先记录好有3台,每成功被申请一次,就减1,减到0时,后面的申请就会被拒绝。


它也可以用mutex和条件变量来实现,但实际上还是用semaphore比较方便。



相关函数


    int sem_destroy(sem_t *sem);
    int sem_init(sem_t *sem, int pshared, unsigned int value);

    int sem_wait(sem_t *sem); // 如果sempahore的数值还够,那就semaphore数值减1,然后进入临界区。也就是传说中的P操作。
    int sem_post(sem_t *sem); // 这个函数会给semphore的值加1,也就是V操作。

    int sem_getvalue(sem_t *sem, int *valp); // 注意了,它把semaphore的值通过你传进去的指针告诉你,而不是用这个函数的返回值告诉你。


要注意的地方


semaphore下的死锁


mutex下的死锁比较好处理,因为mutex只会锁一个资源(当semaphore的值为1时,就是个mutex锁),按照顺序来申请mutex锁就好了。但是到了semaphore这里,由于资源数量不止1个,死锁情况就显得比较复杂。

要想避免死锁,即便采用前面提到的方案:按照顺序加锁,一旦出现加锁失败,就释放所有资源。这招也行不通。假设这样一个情况,当前系统剩余资源情况如下:


剩余资源:      全部系统资源
A:3             A:10
B:2             B:10
C:4             C:10


此时有两个线程:t1, t2。

t1需要3个A,4个B,1个C
t2需要2个A,2个B,2个C  根据当前剩余资源列表来看,t2可以得到执行,不会出现死锁。


假设我们采用旧方案:顺序申请加锁,加锁失败就释放。我们按照CPU时间来推演一个:

    1. t1申请3个A -> 成功
    2. t2申请2个A -> 失败,等待
    3. t1申请4个B -> 失败,等待,并释放3A
    4. t1申请3个A -> 成功
    5. t2申请2个A -> 失败,等待
    6. t1申请4个B -> 失败,等待,并释放3A
    7. t1申请3个A -> 成功
    8. t2申请2个A -> 失败,等待
    9. t1申请4个B -> 失败,等待,并释放3A
    ...


发现没有,这时候t1和t2都得不到执行,但实际上系统的剩余资源是满足t2的要求的,但由于运气不好,抢资源抢不过t1,在有新的资源被释放之前,这俩线程就一直在那儿抢来抢去得不到执行了。

要解决这样的问题,就需要采用银行家算法,银行家算法描述起来很简单:获取所有候选线程的需求,随机取一个假设资源分配出去,看是否能够分配成功。如果不能,就换一个候选者,再进行资源分配。直到有线程满足需求为止。如果都不能满足,那就挂起所有线程,等待新的资源释放。

也就是可以理解成很多个人去贷款,银行家先假设你们都能按期还得起钱,按照你们的需求给你们派钱,不过这不是真的派出去了,只是先写在纸上,银行家一推算,卧槽,到后面会出现资金缺口,那就换一种派发方式,直到没有资金缺口为止。

说白了,你需要在你的程序里面建立一个资源分配者的角色,所有待分配资源的线程都去一个池子里排队,然后这个资源分配者一次只允许一个线程来请求资源,如果请求失败,就换下一个,如果池子里没有线程能够被满足需求,那就集体挂起,然后等有新的资源来了,就再把这些线程一个一个叫过来进行资源分配。




Barriers


Barrier可以理解成一个mile stone。当一个线程率先跑到mile stone的时候,就先等待。当其他线程都到位之后,再从等待状态唤醒,继续做后面的事情。

场景:超大数组排序的时候,可以采用多线程的方案来排序。比如开10个线程分别排这个超大数组的10个部分。必须要这10个线程都完成了各自的排序,你才能进行后续的归并操作。先完成的线程会挂起等待,直到所有线程都完成之后,才唤醒所有等待的线程。

前面有提到过条件变量pthread_join,前者是在做完某件事情通知其他线程,后者是在线程结束之后让其他线程能够获得执行结果。如果有多个线程同时做一件事情,用上面这两者可以有次序地进行同步。另外,用semaphore也可以实现Barrier的功能。

但是我们已经有Barrier了好吗!你们能不要把代码搞那么复杂吗!



相关宏和函数


int pthread_barrier_init(pthread_barrier_t *barrier, const pthread_barrierattr_t *restrict attr, unsigned count);
int pthread_barrier_destroy(pthread_barrier_t *barrier);
int pthread_barrier_wait(pthread_barrier_t *barrier);


pthread_barrier_wait在唤醒之后,会有两种返回值:0或者PTHREAD_BARRIER_SERIAL_THREAD,在众多线程中只会有一个线程在唤醒时得到PTHREAD_BARRIER_SERIAL_THREAD的返回,其他返回都是0。拿到PTHREAD_BARRIER_SERIAL_THREAD返回的,表示这是上天选择的主线程~


要注意的地方


其实Barrier很少被错用,因为本来也没几个函数。更多的情况是有人不知道有Barrier,然后用其他的方式实现了类似Barrier的功能。知道就好了。




关于attr



thread


创建thread的时候是可以设置attr的:detachstateguardsizestackaddrstacksize。一般情况下我都是采取默认的设置。只有在我非常确定这个线程不需要跟其他线程协作的时候,我会把detachstate设置为PTHREAD_CREATE_DETACHED



mutex


创建mutex的时候也是可以设置attr的:process-sharedrobusttype。一般情况下尽量不要出现跨进程的锁共享,万一有个相关进程被酒杀(kill 9)了,而且死之前它抱着锁没放,你后面的事情就麻烦了,基本无解。process-sharedrobust就是跟跨进程有关。

关于type,我强烈建议显式设置为PTHREAD_MUTEX_ERRORCHECK。在Linux下,默认的typePTHREAD_MUTEX_NORMAL。这在下面这种情况下会导致死锁:


void thread_function()
{
    pthread_mutex_lock(&mutex);
    foo();
    pthread_mutex_unlock(&mutex);
}

void foo()
{
    pthread_mutex_lock(&mutex);
    pthread_mutex_unlock(&mutex);
}


上面的代码看着很正常是吧?但由于在调用foo之前,mutex已经被锁住了,于是foo就停在那边等待thread_function释放mutex。但是!thread_function必须要等foo跑完才能解锁,然后现在foo被卡住了。。。

如果type设置为PTHREAD_MUTEX_ERRORCHECK,那在foo里面的pthread_mutex_lock就会返回EDEADLK。如果你要求执行foo的时候一定要处于mutex的临界区,那就要这么判断。

如果type设置为PTHREAD_MUTEX_RECURSIVE,也不会产生死锁,但不建议用这个。PTHREAD_MUTEX_RECURSIVE使用的场景其实很少,我一时半会儿也想不到哪个场景不得不采用PTHREAD_MUTEX_RECURSIVE

嗯,其他应该没什么了吧。




总结


这篇文章主要讲了pthread的各种同步机制相关的东西:mutex、reader-writter、spin、cleanup callbacks、join、condition variable、semaphore、barrier。其中cleanup callbacks不算是同步机制,但是我看到也有人拿这个作为同步机制的一部分写在程序中,这是不对的!所以我才写了一下这个。

文章很长,相信你们看到这里也不容易,看完了这篇文章,你对多线程编程的同步机制应该可以说比较了解了。但我还要说的是,多线程编程的复杂点不仅仅在于同步机制,例如多线程跟系统信号的协作、多线程创建进程后的协作和控制、多线程和I/O之间的协作和控制、函数的可重入性等,我看我什么时候有时间再写这些内容了。




跳出面向对象思想(三) 封装




简述


我认为"封装"的概念在面向对象思想中是最基础的概念,它实质上是通过将相关的一堆函数和一堆对象放在一起,对外有函数作为操作通道,对内则以变量作为操作原料。只留给外部程序员操作方式,而不暴露具体执行细节。大部分书举的典型例子就是汽车和灯泡的例子:你不需要知道不同车子的发动机原理,只要踩油门就可以跑;你不需要知道你的灯泡是那种灯泡,打开开关就会亮。我们都会很直觉地认为这种做法非常棒,是吧?

但是有的时候还是会觉得有哪些地方不对劲,使用面向对象语言的时候,我隐约觉得封装也许并没有我们直觉中认为的那么好,也就是说,面向对象其实并没有我们直觉中的那么好,虽然它已经流行了很多很多年。




1. 将数据结构和函数放在一起是否真的合理?


函数就是做事情的,它们有输入,有执行逻辑,有输出。 数据结构就是用来表达数据的,要么作为输入,要么作为输出。

两者本质上是属于完全不同的东西,面向对象思想将他们放到一起,使得函数的作用被限制在某一个区域里,这样做虽然能够很好地将操作归类,但是这种归类方法是根据"作用领域"来归类的,在现实世界中可以,但在程序的世界中,有些不妥。

不妥的理由有如下几个:

在并行计算时,由于执行部分和数据部分被绑定在一起,这就使得这种方案制约了并行程度。在为了更好地实现并行的时候,业界的工程师们发现了一个新的思路:函数式编程。将函数作为数据来使用,这样就能保证执行的功能在时序上的正确性了。但你不觉得,只要把数据表达和执行部分分开,形成流水线,这不就能够非常方便地将并行数提高了么?

我来举个例子: 在数据和函数没有分开时,程序的执行流程是这样:

A.function1() -> A.function2() -> A.function3()     最后得到经过处理的A

当处于并发环境时,假设有这么多任务同时到达

A.f1() -> A.f2() -> A.f3()     最后得到经过处理的A
B.f1() -> B.f2() -> B.f3()     最后得到经过处理的B
C.f1() -> C.f2() -> C.f3()     最后得到经过处理的C
D.f1() -> D.f2() -> D.f3()     最后得到经过处理的D
E.f1() -> E.f2() -> E.f3()     最后得到经过处理的E
F.f1() -> F.f2() -> F.f3()     最后得到经过处理的F
...

假设并发数是3,那么完成上面类似的很多个任务,时序就是这样

| time | 1   | 2   | 3   | 4   | 5   | 6   | 7   | 8   | 9   | 10  | 11  | 12  |
|------|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|
| A    | A.1 | A.2 | A.3 |     |     |     |     |     |     |     |     |     |
| B    | B.1 | B.2 | B.3 |     |     |     |     |     |     |     |     |     |
| C    | C.1 | C.2 | C.3 |     |     |     |     |     |     |     |     |     |
| D    |     |     |     | D.1 | D.2 | D.3 |     |     |     |     |     |     |
| E    |     |     |     | E.1 | E.2 | E.3 |     |     |     |     |     |     |
| F    |     |     |     | F.1 | F.2 | F.3 |     |     |     |     |     |     |
| G    |     |     |     |     |     |     | G.1 | G.2 | G.3 |     |     |     |
| H    |     |     |     |     |     |     | H.1 | H.2 | H.3 |     |     |     |
| I    |     |     |     |     |     |     | I.2 | I.2 | I.3 |     |     |     |
| J    |     |     |     |     |     |     |     |     |     | J.1 | J.2 | J.3 |
| K    |     |     |     |     |     |     |     |     |     | K.1 | K.2 | K.3 |
| L    |     |     |     |     |     |     |     |     |     | L.1 | L.2 | L.3 |

当数据和函数分开时,并发数同样是3,就能形成流水线了,有没有发现吞吐量一下子上来了?

| time | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10| 11| 12|
|------|---|---|---|---|---|---|---|---|---|---|---|---|
| f1() | A | B | C | D | E | F | G | H | I | J | K | L |
| f2() | Z | A | B | C | D | E | F | G | H | I | J | K |
| f3() | Y | Z | A | B | C | D | E | F | G | H | I | J |

你要是粗看一下,诶?怎么到了第13个周期K才刚刚结束?上面一种方案在第12个周期的时候就结束了?不能这么看的哦,其实在12个周期里面,Y、Z也已经交付了。因为流水线吞吐量的提升是有过程的,我截取的片段应该是机器在持续运算过程中的一个片段。

我们不能单纯地去看ABCD,要看交付的任务数量。在12个周期里面,大家都能够完成12个任务,在11个周期里面,流水线完成了11个任务,前面一种只完成了9个任务,流水线的优势在这里就体现出来了:每个时间段都能稳定地交付任务,吞吐量很大。而且并发数越多,跟第一种方案比起来的优势就越大,具体的大家也可以通过画图来验证。


数据部分就是数据部分,执行部分就是执行部分,不同类的东西放在一起是不合适的

函数就是一个执行黑盒,只要满足函数调用的充要条件(给够参数),就是能够确定输出结果的。面向对象思想将函数和数据绑在一起,这样的封装扩大了代码重用时的粒度。如果将函数和数据拆开,代码重用的基本元素就由对象变为了函数,这样才能更灵活更方便地进行代码重用。

嗯,谁都经历过重用对象时,要把这个对象所依赖的所有东西都要移过来,哪怕你想用的只是这个对象里的一个方法,然而很有可能你的这些依赖是跟你所需要的方法无关的。

但如果是函数的话,由于函数自身已经是天然完美封装的了,所以如果你要用到这个函数,那么这个函数所有的依赖你都需要,这才是合理的。




2. 是否所有的东西都需要对象化?


面向对象语言一直以自己做到"一切皆对象"为荣,但事实是:是否所有的东西都需要对象化?

在iOS开发中,有一个类叫做NSNumber,它封装了所有数值:double,float,unsigned int, int...等等类型,在使用的时候它弱化了数值的类型,使得非常方便。但问题也来了,计算的时候是不能直接对这个对象做运算的,你得把它们拆成数值,然后进行运算,然后再把结果变成NSNumber对象,然后返回。这是第一点不合理。第二点不合理的地方在于,运算的时候你不知道原始数据的类型是什么,拆箱装箱过程中难免会导致内存的浪费(比如原来uint8_t的数据变成unsigned int),这也十分没有必要。

还有就是我们的file descriptor,它本身是一个资源的标识号,如果将资源抽象成对象,那么不可避免的就会使得这个对象变得非常庞大,资源有非常多的用法,你需要将这些函数都放到对象里去。在真正传递资源的时候,其实我们也只是关心资源标识而已,其它的真的无需关心。

我们已经有函数作为黑盒了,拿着数据塞到黑盒里就够了。




3. 类型爆炸


由于数据和函数绑定到了一起,在逻辑上有派生关系的两种对象往往可以当作一种,以派生链最上端的那个对象为准。单纯地看这个现象直觉上会觉得非常棒,父亲有的儿子都有。但在实际工程中,派生是非常不好控制的,它导致同一类类型在工程中泛滥:ViewController、AViewController、BViewController、ThisViewController、ThatViewController...

你有没有发现,一旦把执行和数据拆解开,就不需要这么多ViewController了,派生只是给对象添加属性和方法。但事实上是这样:

struct A {              Class A extends B
    struct B b;         {
    int number;             int number;
}                       {

前者和后者的相同点是:在内存中,它们的数值部分的布局是一模一样的。不同点是:前者更强烈地表达了组合,后者更强烈地表达的是继承。然而我们都知道一个常识:组合要比继承更加合适,这在我这一系列的第一篇文章中有提到。

上两者的表达在内存中没有任何不同,但在实际开发阶段中,后者会更容易把项目引入一个坏方向。




总结


为什么面向对象会如此流行?我想了一下业界关于这个谈论的最多的是以下几点:


  1. 它能够非常好地进行代码复用
  2. 它能够非常方便地应对复杂代码
  3. 在进行程序设计时,面向对象更加符合程序员的直觉


第一点在理论上确实成立,但实际上大家都懂,在面向对象的大背景下,写一段便于复用的代码比面向过程背景下难多了。关于第二点,你不觉得正是面向对象,才把工程变复杂的么?如果层次清晰,调用规范,无论面向对象还是面向过程,处理复杂业务都是一样好,等真的到了非常复杂的时候,对象间错综复杂的关系只会让你处理起来更加头疼,不如面向过程来得简洁。关于第三点,这其实是一个障眼法,因为无论面向什么的设计,最终落实下来,还是要面向过程的,面向对象只是在处理调用关系时符合直觉,在架构设计时,理清需求是第一步,理清调用关系是第二步,理清实现过程是第三步。面向对象让你在第二步时就产生了设计完成的错觉,只有再往下落地到实现过程的时候,你才会发现第二步中都有哪些错误。

所以综上所述,我的观点是:面向对象是在架构设计时非常好的思想,但如果只是简单映射到程序实现上来,引入的缺点会让我们得不偿失。





后记


距离上一次博文更新已经快要一个月了,不是我偷懒,实在是太忙,现在终于有时间可以把"跳出面向对象"系列完成了。针对面向对象的3个支柱概念我写了三篇文章来挑它的刺,看上去有一种全盘否定的感觉,而我倒不至于希望大家回去下一个项目就开始面向过程的开发,我希望大家能够针对这一系列文章提出的面向对象的弊端,严格规范代码的行为,知道哪些可行哪些不可行。过去的工作中我深受其苦,往往没有时间去详细解释为什么这么直觉的东西实际上不可行,要想解释这些东西就得需要各种长篇大论。最痛苦的是,即便长篇大论说完了,最后对方还无法理解,照样写出垃圾代码出来害人。

现在好了,长篇大论落在纸上了,说的时候听不懂,回去总可以翻文章慢慢理解了吧。




C程序的内存管理




简述


自从自动内存管理嵌入到各种各样的语言之后,我们就很少会去关注这方面的事情了,这些功能的设计者和实现者们为此付出的努力值得我们称赞,期间也涌现了多种不同的内存管理方案。目前大部分语言的主流内存管理方案是Garbage Collector。苹果推出的Auto Reference Count也因为基于编译器自动添加手工计数的代码而带来了更好的性能提升。我在这里总结了一些从上古时代到如今在C程序下进行内存管理的技术。同时,也为你更深入地了解其他语言中内存管理模块的原理提供了知识背景。




管理固定大小的内存




栈内存


栈内存应该是最容易管理的了,只要理解生存域就能理解占内存的管理方式。生存域就是这样:


    int main ()
    {
        int array[] = {1,2,3,4,5};
                              ------------------|
        if ( i > 0 ) {                          |
            ...                                 |
            ...                                 |
        }                                       |
        foo(array);                             |
                                                |
        {                                       |
            int k = 9;        ---               |
            array[1] = k;       |->k的生存域     |->array的生存域
            ...               ---               |
        }                                       |
    }                                           |
                                                |
    void foo(int * const number) {              |
        int j = 0;                              |
                              ---               |
        ...                     |               |
        number[2] = j;          |->j的生存域     |
        ...                     |               |
                              -------------------
    }

基本上就是变量所处{}之间的区域都叫这个变量的生存域。当程序执行到对应}的时候,就会将这个}对应生存域内的所有变量的内存释放给操作系统。



优点


  • 程序员不需要为此做任何额外的事情,不用添加变量记录使用轨迹,也不用在内存中额外记录引用次数,程序自己会帮你处理好这些问题的


  • setjmplongjmp中,栈内存是安全的

setjmplongjmp是C99引入的可以模仿C++的exception行为的一种方案。一般情况下,所有在longjmp的内存在声明的时候都需要加volatile,让程序每次都从内存读取数值而不是在cache中读取。然而对于栈内存而言,可以不用volatile


  • 在多线程环境,递归环境,异步信号处理中,栈内存都是安全的


缺点


  • 一般情况下,程序中都会有栈内存和堆内存混合使用,当函数在使用指针类的形参时,因为难以区分这个指针对应的内存是栈内存还是堆内存,因此就很难采取正确的内存管理措施。

上面的例子中,foo函数接收了一个指针参数,如果这个指针是堆内存,那么就需要进行引用计数加一的操作(具体操作取决于内存管理方式),但现在在foo函数中其实无法区分这到底是栈内存还是堆内存,境况就比较尴尬了。


  • 你必须要知道你的栈内存应当分配多大才合适,否则会引发安全问题

函数调用栈和变量内存栈其实是共用的,当内存溢出的时候,会覆盖掉对应的函数调用栈,这种覆盖不会引起程序中止或其他情况,十分安静。当函数执行完毕后,操作系统会取栈顶第一个元素作为跳转地址,然后就会直接跳转到被溢出的地址上了。举个例子:


exploit.c:

    int main() {
        char string[1];
        printf("here i am");
        scanf("%s", string);
        return 0;
    }


第一步程序进入main,然后为string分配了一个栈内存,此时栈内情况:

           |--------|
    top -> | string |
           |________|


然后进入scanf函数,记住,操作系统记录函数调用的栈和存放栈变量的栈是同一个!此时栈内情况:

            |--------------|
    top ->  | back address |  # 此时back address指向main函数中的 return 0; 这一指令的地址:0x1234
            |    0x1234    |
            |______________|
            |              |
            |    string    |
            |______________|


然后你在终端输入远大于string长度的数据,比如'npppp...',然后回车,此时已经内存溢出,但scanf函数还未结束,此时程序也不会中止。此时栈内情况:

           |--------|
    top -> | pppppp |   # 原来的back address被溢出成pppppp,而不是原来的0x1234
           |________|
           |        |
           | string |
           |________|


然后scanf函数完成任务,取栈顶地址返回,此时就会将ppppp取出,作为地址返回。由于这个地址是非法地址,程序此时就会报can't access的错误,程序终止。我们可以更加别有用心一点,计算好溢出长度(一般情况下溢出长度跟栈内存的长度不一样,需要通过fuzz手段来确定对应长度),将溢出后覆盖的地址设为可以访问的地址,比如这里假设printf("here i am");的地址为0x1230,我们在输入string的时候输入npp...pp1230(此处的1230其实是对应1230这个数字的ASCII码),那么栈内情况就会变成:

           |--------|
    top -> | 0x1230 |   # printf("here i am");
           |________|
           |        |
           | string |
           |________|


输入完毕按回车,你会看到程序就跑到printf那边去了,又输出了一次"here i am"。这就是一个典型的内存溢出漏洞,及其攻击方式。如果使用的是堆内存,在内存溢出之后因为溢出的那部分的内存使用权在管态,程序会直接中止,并且报address can't access的错误。


  • 内存的合法访问范围被限制在一个固定的区域中

大多数情况我们其实是希望在另一个生存域中也能访问到对应内存的,使用栈内存就很容易出现野指针,因为你不知道什么时候它就被回收了。只有在递归调用的时候,栈内存才相对比较安全。


  • 拥有这个栈内存的函数不能将这个栈内存的指针返回出去

这同样也是由于在这个函数以外的地方已经超出了变量生存域的原因。return出去的就变成野指针了。


总结


由于栈内存有以上这些优缺点(特点),我们一半都不太会用栈变量来管理广泛使用的内存和大片内存。一方面是由于栈变量有生存域限制,广泛使用的内存基本上都不能有这些限制。另一方面也是C程序调用参数的方案主要是值传递,大片内存一般都不会传整个变量,这样会导致操作系统copy的时候开销太大,如果使用传址调用,则会遇到被调用函数不知道如何管理指针所对应的那片内存的尴尬。



静态分配内存


所谓静态分配内存,就是在最外面的地方使用static来声明变量,由于这个变量被声明成了全局静态变量,这个变量所占有的内存也就变成了全局静态内存,即使是跨文件的情况也能通过变量名访问这片内存。这种做法突破了栈内存关于生存域的限制。

优点


  • 这是解决生存域问题最简单的方案,全局可用。

由于这个是全局变量,任何地方想要用到它,只要extern一下就好。它在程序刚起来的时候(正式调用main函数之前)就会被操作系统分配,直到程序退出才会被操作系统回收。如果你的这个变量是一个全局单例,使用这种方案就不太容易会出错。


  • 由于这是单独的一片内存区域,函数可以将最终结果存放在这个区域,然后返回指针,避免了值传递的低效率。

实际做法可以先申请一大片内存,比如uint8_t buffer[65536];,然后函数把生产出来的结果存放在buffer的某个位置,比如buffer[10],然后把buffer[10]的指针作为结果return出去。buffer[20]或者其他的地方就可以给其他函数使用了。只要调用者和被调用者约定好偏移,这么做问题就不大。但如果涉及多线程、信号处理、递归等情况时,这种做法就不行了。另外一种情况就是这样的做法内存利用效率也不高,内存碎片会比较多,因为我们往往在约定的时候会划一个比较大的范围,大部分情况下,这一块很少是被完整利用的。

你也可以用循环使用的方法,比如说这一次使用0-9,下一次使用10-19,再下一次使用20-29这样,然后用完了再回头从0开始用,这样风险和成本都会不小,你要协调好各自函数操作这个buffer的关系,尽量少出bug。不过这正是编程的乐趣所在,不是么?:D

还有一种终极方案,需要结合下面提到的类似引用计数的方案来操作:在申请了这片buffer的同时,维护一个bitmap,1表示对应位置内存有用,0表示对应位置内存可以另作他用。通过引用计数的方案来维护这个bitmap,这样每次需要使用内存的时候,通过bitmap就可以找到有效内存的偏移,这样就总能保证取出的内存是可以使用的。这种方案相对而言还是比较广泛的,能在分配偏移的时候根据bitmap的情况采取一些策略来减少碎片的产生。但由于是静态buffer,因此就要求程序使用内存的数量可预测。


  • 在静态内存中存的值会一直都在,你可以拿它作为多线程交换数据用,或者作为跨函数的中间结果的缓存用。

举个例子:


    static int globalVariable;

    char foo() {
        ...
        globalVariable = mid_result;    // globalVariable存储了foo()函数运行过程中的一个中间结果
        ...
        return result;                  // foo()函数的真正任务是返回result
    }

    void bar() {
        ...
        something = count + globalVariable; // bar()函数需要使用foo()函数的某个中间结果来完成任务,foo()正好存储了这个中间结果,可以直接拿来给bar()用
        ...
    }


缺点


  • 这是解决生存域问题最粗暴的方案,业界喷的也比较多

它虽然保证了内存全局可用,但由于只有程序结束之后对应的内存才能被回收,这导致内存的使用效率不高。另一方面,全局变量很容易造成代码恶化,当这个变量被多个使用者使用的时候,你就不能保证你每次从这里取出来的数据一定是你期望的数据,因此在设计的时候我们一般都是需要谨慎考虑是否要引入全局变量的。


  • 容易引发命名空间的问题

由于全局变量哪儿都有用,一旦出现两个重名的全局变量就坑爹了,这样的bug还特别难调,只有特别熟悉整个项目代码的人才能想到可能是重名导致了问题。


  • 多线程情况下会有坑

由于它全局变量的特点,我们可能会想到使用它来作为不同线程交换数据的地方。这是可以做到的,但是需要对这个变量进行临界区改造,不光要保证同一时间只有一个线程使用它,还要根据具体需求,保证同一功能区块内,只有一个线程使用它。否则就会出现1号线程还没完全跑完这个变量相关的代码。就被2号线程给把值改了,后面1号线程怎么跑就都不会出正确结果了。做临界区改造的成本还是蛮大的,一般都是使用PV操作来执行这样的任务,需要为此额外开辟内存来记录变量引用次数,以及设计一个runloop使之能够让其他线程执行wait()操作。



动态分配内存,并使用引用计数来管理


这是相对简单且容易采用的办法,实现方案有很多。实际使用时候的体验就类似于苹果在没推出ARC (Automatic Reference Count) 时候的MRC(Manual Reference Count)。具体原理是这样:


    1. 使用malloc()或calloc()申请内存,并为这个内存初始化引用次数为1
    2. 在内存使用过程中,如果当前片段需要成为这块内存的拥有者,那就调用一个方法使得这个内存的引用值加一
    3. 当拥有者使用这块内存完毕,就再调用一个方法使得这个内存的引用值减一
    4. 当引用值为0时,调用free(),让操作系统将内存回收


然后具体的实现方案也可以是以下这些:

  1. 让引用计数跟随指针的方案

可以将内存、存储引用计数的变量打包成一个struct,然后以这个struct为单位去向操作系统要内存。操作引用计数的函数就只要修改这个struct的引用计数变量就好了,当引用计数变量值为0时,自动调用free()函数就好。


  1. 引用计数不跟随指针的方案

先申请一大片内存,然后再开辟一个bitmap负责维护这一大片内存的使用情况,bitmap中的0表示某一区域的内存可用,其它数字则表示这片区域内存的引用计数。每次代码需要申请内存的时候,根据bitmap的情况从它维护的这一大片内存中挖出一块来给出去。当那一大片内存不够用的时候,就再申请一大片,然后再开辟一个bitmap来维护这片新的内存,当某一大片内存使用过后,bitmap数值全都是0的时候,就把这一大片内存一起回收掉。总的来看就是一大片内存由bitmap维护,然后bitmap通过引用计数来维护。


方案1的优点


  • 内存碎片极小,约等于没有

因为是随用随取,基本上不太可能出现有内存碎片的情况,内存使用效率高。


  • 实现方案简单

其实只要定义一个struct,然后写一个retain()函数(用于增加引用计数),和一个release()函数(用于减小引用计数),把相关逻辑都封装在这两个方法里面就好了。特别简单。


方案1的缺点


  • 性能低下

这种方案有潜在的可能去频繁申请和释放内存,然而这两种操作,尤其是内存申请,是很消耗性能的。对于一般的客户端程序来说,或许可以忍受,但是在服务端程序中,往往都会因为这个缺点而采取方案2。


方案2的优点


  • 性能好

由于一次申请了一大片内存,只要这片内存还够,后续的内存需求都可以不需要通过向操作系统来申请,性能就好很多。


方案2的缺点


  • 长时间运行后,内存会有大量内存碎片的存在

第一轮使用内存的时候,差不多是可以做到内存碎片很小的。随着运行时间的增长,bitmap中维护的内存来来回回地被重用之后,就会出很多的内存碎片。而且这时往往不适合进行内存碎片整理,否则会造成很多野指针。假设运行期间一共分了10片大内存,极端情况就是10片大内存每片都只是使用了很小的一块,由于不能做内存整理,就导致内存浪费比较大。某种程度上讲,这属于内存泄漏。


  • 实现方案相对复杂

你要实现一个bitmap,以及bitmap相关的维护函数,比方案1会复杂很多。


关于方案1和方案2的总结


你会发现,方案1的优点就是方案2的缺点,方案1的缺点就是方案2的优点,具体采用哪种方案,是要通过实际情况考虑的。如果考虑性能更多,那就选择方案2,如果考虑内存更加高效,那就选择方案1。事实上针对方案2容易导致的内存碎片问题,也有一个优化方案,就是再额外做一层抽象,让逻辑上连续的内存在实际上可以不连续。于是我们存放数据的时候可以根据bitmap的碎片进行见缝插针式的内存使用。这个优化方案看上去很好,但实际上增大了代码的复杂度,你需要维护一个B树来进行这个抽象,在实际应用中有点儿得不偿失。

这一类目还没结束,上面只是讨论了实现引用计数的两种方案的优缺点,下面我要说一下使用引用计数来进行内存管理其自身的优缺点。这些优缺点在方案1和方案2中都是普遍存在的。

引用计数方案的优点


  • 提高内存使用效率

从宏观上看,引用计数方案能够提高内存使用效率,程序能够通过引用计数来知道哪片内存不再使用了,这片不再使用的内存就能够及时被操作系统回收。


  • 没有生存域限制,没有命名空间限制

内存只要被malloc后,就一直可用,除非被free。由于不通过一个全局变量来hold住这块内存,我们可以不用关心命名空间冲突的问题。


引用计数方案的缺点


  • 对程序员有更加高的要求

相比于栈内存来说,进行动态内存分配要写的代码量要更大一些,管理要更细致一些,否则很容易出内存泄漏或者野指针的问题。何时进行引用计数的加一,何时进行引用计数的减一,这些需要程序员能够了解得非常清楚,有时候忘记调用增一或减一的方案,就会引发bug。调用这些辅助方法本身跟业务逻辑无关,但它们确实会影响业务逻辑。

在某些情况下也容易出现循环引用,从而导致内存无法被回收,比如这样:


    A: { ... B.a ... }
    B: { ... A.a ... }


当要回收A的内存时,由于它声明了B.a的所有权,A的内存是不能被回收的,只有等B被回收后,A才能被回收。然而此时B又对A.a声明了所有权,B是不能被回收的,因此形成了一个死循环(有一个术语专门用来描述这种死循环:retain cycle),造成A和B都不能被回收,从而导致内存泄漏。一般情况下要避免这个问题,就要分清楚内存所对应的变量在你的程序中的逻辑层级关系。一般是高层级的变量拥有低层级的变量(对该变量进行引用加一),低层级的变量不拥有高层级的变量(引用计数不加一),遵守这个原则就能够避免retian cycle的出现。


  • longjmp、setjmp的情况下很难处理引用计数对应的内存

前面说过longjmp、setjmp就类似于exception机制。在某一段代码触发longjmp的时候,这段代码所相关的变量对应的内存其实就已经不需要再用了,这时候这些内存的引用计数往往都大于0,甚至在当前上下文你都不一定能够确定哪些变量是有用哪些变量是无用的,这时你就无法通过引用计数机制来回收内存。

不过说起来exception机制在刚从C++诞生的时候业界都很欢乐,终于有了一种程序出错而可以不中止程序的方案了。但随着时间的推进,人们越来越认识到exception机制有很多坑,近年大家都不推荐在程序中使用exception机制来处理运行时错误,更多的是采用error number,error对象的机制。这方面具体的讨论不在本文范围内,大家可以各自Google一下。



链式内存分配


这是为了解决引用计数在longjmp和setjmp情况下内存不容易管理的缺点。它是这样实现的:

1. 实现一个链表
2. 链表的节点包含指向已经申请内存的指针这块内存的引用计数这块内存当前所处的函数指针用于标志作用域
3. 每次申请内存的时候都生成一个这样的节点然后挂在链表上
4. 每次释放内存的时候都将这个节点删除并free()对应的内存
5. longjmp之后根据函数指针遍历链表找到所有对应函数指针的节点计数减一若减一之后计数为0则释放内存

实质上就是用链表来进行内存使用的跟踪,这样的链表在程序中可以一个也可以多个,然后由一个总表去维护这些链表,这么做可以防止遍历太长的链表造成太大的性能消耗。

优点


  • longjmp、setjmp有效

这个自然不必多说,这个方案就是为解决这个问题而诞生的。


  • 链表带来了非常好的灵活性

实际操作中,可以每个模块一个链表,甚至每个功能一个链表。链表不光可以用于longjmp,你也可以写一个仅在debug模式下启用的功能,这个功能用于统计每个函数内存的使用量,这在调试优化的时候是个非常好的数据来源。


缺点


  • 依旧不能解决retain cycle的问题

链式内存分配的方案本质上还是属于引用计数,只是解决了exception情况下的内存处理,但并没有解决retain cycle,程序员依旧需要当心这种情况。




管理不是固定大小的内存


长度可变的数组


这个是C99引入的新特性,就是这样:

    void foo(int n) {
        int array[n];
        array[n-1] = 1;
        printf("%d", array(n-1));
    }

大部分C语言教程会说这种写法是错误的。因为array占用的是栈内存,栈内存是不能在运行期间动态分配的。但自从C99标准之后,这种写法就不会引起编译错误了。长度可变的数组倒是一个蛮不错的功能,以前要实现长度可变的数组,大部分都会用链表去做,使用这个功能之后就省事儿很多了。而且栈内存也是内存呀,当然可以拿它来做别的事情了~

优点


  • 栈内存的所有优点它都有

多线程,安全。exception,安全。递归,安全。异步信号处理,安全。不用写额外的内存管理代码,方便。


  • 类似动态内存分配的效果

你可以将长度作为参数去构建你的数组,然后将这片内存另做它用。这是原始的栈内存分配不能做到的。


缺点


  • 栈内存的所有缺点它都有

生存域限制,接收参数的内存管理方案易混淆,这些都依旧是缺点。唯一好的地方就是你可以不用知道传递的数据有多大了,但是即便这样,还是不能做到真正动态内存那般随用随取,因此使用的时候也还是会有颇多限制。



动态内存重分配


有时候一片内存不够大,但是又不适合再开辟一个新内存,你就会需要将原来的内存进行重新分配。比如说你有一个缓冲区用于存放数据,当数据大于缓冲区容量时,你需要让缓冲区容量变大。如果开辟一个新内存,缓冲区的连续性就破被破坏了,所以你会对缓冲区进行内存的重新分配,这样就能放得下数据了。一般来说,这是对未知长度的数据的一种处理方案,重分配的函数就是realloc()。重分配时,操作系统会将旧的那片内存的数据复制到新的扩展过的内存里,这样就能保证连续性了。

优点


  • 能够跟前面提到的动态内存管理方案相结合,提供连续的,更大片的内存


  • 因为本质还是动态内存操作,所以动态内存所有的优点它都有


  • 可以引申一种新的内存管理方案

我们可以约定由函数的调用者负责申请和管理内存,然后将指针传递进去,子函数发现内存不够的时候,重新分配一下就行。然后这片内存的管理工作就由调用者来管理,子函数运行期间,内存都是可用的。子函数运行完毕,父函数拿到结果之后,一般来说也就是直接free了。这种方案其实也是相对使用比较广泛的一种内存管理方案。


缺点


  • 性能消耗厉害

因为有一个额外的copy操作,如果你频繁进行重分配的话,copy和内存申请都会带来更多的性能消耗,一半儿而言重分配的时候也尽量大片大片地扩展,免得出现经常不够用然后经常要重分配的情况。


  • 动态内存管理的缺点也都有

你还是需要引入其他的内存管理方案来管理重分配的内存,前面提到了很多动态内存管理的方案,在这个时候也都需要根据情况采用,而且在扩展内存方面,由于扩展后是一片新内存,旧的指针变量保存的地址就会失效成为野指针。



内存回收器(GC,Garbage Collector)


GC的话题太大,实现方案也多种多样,有跟踪内存使用路径的,也有基于引用计数的。C环境下有一个libgc库实现了一个内存回收器,惠普也搞过一个GC,BDWgc,使用的人也很多。目前业界大部分语言是自带GC的,java,python,php,javascript都有,各自实现的方案也不一样,改造改造移植到C来也不是不行。所以真要评个优点缺点很难,因为不太好拿一个具象的东西进行分析。一般认为GC是内存管理的终极方案,虽然也有优缺点,但是基本上都优于上面提到的各种方案,而且还省时省力。只是我们要注意的是,同一套程序里不能同时存在两种GC,然而同一套程序里是可以存在多种内存管理方案的,这算是相对普适的一个特点了吧。




总结


C环境下面做内存管理其实是个苦力活,而且也没有什么全能方案能够解决所有的问题。我个人倾向使用引用计数,同时不使用exception机制来处理程序错误。写这篇文章的目的也是为了总结一下各种情况下的内存管理方案。文末推荐一篇文章,是教你如何写一个属于自己的Memory Manager的,虽然不是GC,但也属于相对成熟的一种内存管理方案。比较长,也有点儿难度,但你都把我文章看完了,相信你再看那篇文章应该不是问题。





❌