philon的博客

一个菜鸟程序员的逆袭

IOS平台Hotfix框架:JSPatch学习笔记及和WaxPatch的集成

关于HotfixPatch

在IOS开发领域,由于Apple严格的审核标准和低效率,IOS应用的发版速度极慢,稍微大型的app发版基本上都在一个月以上,所以代码热更新(HotfixPatch)对于IOS应用来说就显得尤其重要。

现在业内基本上都在使用WaxPatch方案,由于Wax框架已经停止维护四五年了,所以waxPatch在使用过程中还是存在不少坑(比如参数转化过程中的问题,如果继承类没有实例化修改继承类的方法无效, wax_gc中对oc中instance的持有延迟释放…)。另外苹果对于Wax使用的态度也处于模糊状态,这也是一个潜在的使用风险。

随着FaceBook开源React Native框架,利用JavaScriptCore.framework直接建立JavaScript(JS)和Objective-C(OC)之间的bridge成为可能,JSPatch也在这个时候应运而生。最开始是从唐巧的微信公众号推送上了解到,开始还以为是在React Native的基础上进行的封装,不过最近仔细研究了源代码,跟React Native半毛钱关系都没有,这里先对JSPatch的作者(不是唐巧,是Bang,博客地址)赞一个。

深入了解JSPatch之后,第一感觉是这个方案小巧,易懂,维护成本低,直接通过OC代码去调用runtime的API,作为一个IOS开发者,很快就能看明白,不用花大精力去了解学习lua。另外在建立JS和OC的Bridge时,作者很巧妙的利用JS和OC两种语言的消息转发机制做了很优雅的实现,稍显不足的是JSPatch只能支持ios7及以上。

由于现在公司的部分应用还在支持ios6,完全取代Wax也不现实,但是一些新上应用已经直接开始支持ios7。个人觉得ios6和ios7的界面风格差别较大,相信应用最低支持版本会很快升级到ios7. 还考虑到JSPatch的成熟度不够,所以决定把JSPatch和WaxPatch结合在一起,相互补充进行使用。下面给大家说一些学习使用体会。

JSPatch和WaxPatch对比

关于JSPatch对比WaxPatch的优势,下面摘抄一下JSPatch作者的话:

方案对比

目前已经有一些方案可以实现动态打补丁,例如WaxPatch,可以用Lua调用OC方法,相对于WaxPatch,JSPatch的优势:

  • 1.JS语言: JS比Lua在应用开发领域有更广泛的应用,目前前端开发和终端开发有融合的趋势,作为扩展的脚本语言,JS是不二之选。

  • 2.符合Apple规则: JSPatch更符合Apple的规则。iOS Developer Program License Agreement里3.3.2提到不可动态下发可执行代码,但通过苹果JavaScriptCore.framework或WebKit执行的代码除外,JS正是通过JavaScriptCore.framework执行的。

  • 3.小巧: 使用系统内置的JavaScriptCore.framework,无需内嵌脚本引擎,体积小巧。

  • 4.支持block: wax在几年前就停止了开发和维护,不支持Objective-C里block跟Lua程序的互传,虽然一些第三方已经实现block,但使用时参数上也有比较多的限制。

JSPatch的劣势:

  • 相对于WaxPatch,JSPatch劣势在于不支持iOS6,因为需要引入JavaScriptCore.framework。另外目前内存的使用上会高于wax,持续改进中。

JSPatch的实现原理理解

JSPatch的实现原理作者的博文已经很详细的介绍了,我这里就不多说了,贴一下学习之处:

看实现原理详解的时候对照着源码看,比较好理解,我在这里说一下我对JSPatch的学习和理解:

(1)OC的动态语言特性

不管是WaxPatch框架还是JSPatch的方案,其根本原理都是利用OC的动态语言特性去动态修改类的方法实现。 OC的动态语言特性是在runtime system(全部用C实现,Apple维护了一份开源代码)上实现的,面向对象的Class和instance机制都是基于消息机制。我们平时认为的[object method],正确的理解应该是[receiver sendMsg], 所有的消息发送会在编译阶段编译为runtime c函数的调用:_obj_sendMsg(id, SEL).

详细介绍参考博文:

runtime提供了一些运行时的API

  • 反射类和选择器
1
2
Class class = NSClassFromString("UIViewController");
SEL selector = NSSelectorFromString("viewDidLoad");
  • 为某个类新增或者替换方法选择器(SEL)的实现(IMP)
1
2
BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types);
IMP class_replaceMethod(Class cls, SEL name, IMP imp, const char *types);
  • 在runtime中动态注册类
1
2
3
Class superCls = NSClassFromString(superClassName);
cls = objc_allocateClassPair(superCls, className.UTF8String, 0);
objc_registerClassPair(cls);

(2)JS如何调用OC

在JS运行环境中,需要解决两个问题,一个是OC类对象(objc_class)的获取,另一个就是使用对象提供的接口方法。

对于第一个问题,JSPatch在实现中是通过Require调用在JS环境下创建一个class同名对象(js形式),当向OC发送alloc接收消息之后,会将OC环境中创建的对象地址保存到这个这个js同名对象中,js本身并不完成任何对象的初始化。关于JS持有OC对象的引用,其回收的解释在JSPatch作者的博文中有介绍,没有具体测试。详见JSPatch.js代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//请求OC类对象
UIView = require("UIView");

//缓存JS class同名对象
var _require = function(clsName) {
    if (!global[clsName]) {
      global[clsName] = {
        __isCls: 1,
        __clsName: clsName
      }
    } 
    return global[clsName]
      }

//调用class方法,返回OC实例化对象进行封装
var ret = instance ? _OC_callI(instance, selectorName, args, isSuper):
                     _OC_callC(clsName, selectorName, args)
                     
//OC创建后返回对象
return@{@"__clsName": NSStringFromClass([obj class]), @"__obj": obj};
    

//JS中解析OC对象
return _formatOCToJS(ret)

//_formatOCToJS
if (obj instanceof Object) {
    var ret = {}
    for (var key in obj) {
      ret[key] = _formatOCToJS(obj[key])
    }
    return ret
 }

对于第二个问题,JSPatch在JS环境中通过中心转发方式,所有OC方法的调用均是通过新增Object(js)原型方法c(methodName)完成调用,在通过JavaScriptCore执行JS脚本之前,先将所有的方法调用字符替换 c(‘method’)的方式; 在_c函数中通过JSContex建立的桥接函数传入参数和返回参数即完成了调用;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//字符替换
static NSString *_regexStr = @"\\.\\s*(\\w+)\\s*\\(";
static NSString *_replaceStr = @".__c(\"$1\")(";

NSString *formatedScript = [NSString stringWithFormat:@"try{@}catch(e){_OC_catch(e.message, e.stack)}", [_regex stringByReplacingMatchesInString:script options:0 range:NSMakeRange(0, script.length) withTemplate:_replaceStr]];


//__c()向OC转发调用参数
Object.prototype.__c = function(methodName) {
    
    ...
    
    return function(){
      var args = Array.prototype.slice.call(arguments)
      return _methodFunc(self.__obj, self.__clsName, methodName, args, self.__isSuper)
    }
 }
  
//_methodFunc调用桥接函数
var _methodFunc = function(instance, clsName, methodName, args, isSuper) {
  
  ...
  
    var ret = instance ? _OC_callI(instance, selectorName, args, isSuper):
                         _OC_callC(clsName, selectorName, args)

    return _formatOCToJS(ret)
 }


//OC中的桥接函数,JS和OC的桥接函数都是通过这样定义
context[@"_OC_callI"] = ^id(JSValue *obj, NSString *selectorName, JSValue *arguments, BOOL isSuper) {
    return callSelector(nil, selectorName, arguments, obj, isSuper);
};

context[@"_OC_callC"] = ^id(NSString *className, NSString *selectorName, JSValue *arguments) {
    return callSelector(className, selectorName, arguments, nil, NO);
};

(3)JS如何替换OC方法

JSPatch的主要作用还是通过脚本修复一些线上bug,希望能够达到替换OC方法的目标。JSPatch的实现巧妙之处在于:利用了OC的消息转发机制

  • 1:替换原有selector的IMP实现为一个空的IMP实现,这样当objc_class接受到消息之后,就会进行消息转发, 另外需要将selector的初始实现进行保存;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//selector指向空实现
IMP msgForwardIMP = getEmptyMsgForwardIMP(typeDescription, methodSignature);
class_replaceMethod(cls, selector, msgForwardIMP, typeDescription);


//保存原有实现,这里进行了修改,增加了恢复现场的支持
NSString *originalSelectorName = [NSString stringWithFormat:@"ORIG@", selectorName];
SEL originalSelector = NSSelectorFromString(originalSelectorName);
if(class_respondsToSelector(cls, selector)) {
    if(!class_respondsToSelector(cls, originalSelector)){
        class_addMethod(cls, originalSelector, originalImp, typeDescription);
    } else {
        class_replaceMethod(cls, originalSelector, originalImp, typeDescription);
    }
}
  • 2:将替换的JS方法构造一个JPSelector及其IMP实现(根据返回参数构造),添加到当前class中,并通过cls+selecotr全局缓存JS方法(全局缓存并没有多大用途,但是对于后面恢复现场比较有用);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (!_JSOverideMethods[clsName][JPSelectorName]) {
    _initJPOverideMethods(clsName);
    _JSOverideMethods[clsName][JPSelectorName] = function;
    const char *returnType = [methodSignature methodReturnType];
    IMP JPImplementation = NULL;
    
    //根据返回类型构造
    switch (returnType[0]){
     ...
    }
 
  if(!class_respondsToSelector(cls, JPSelector)){
        class_addMethod(cls, JPSelector, JPImplementation, typeDescription);
    } else {
        class_replaceMethod(cls, JPSelector, JPImplementation,typeDescription);
    }
}
  • 3:然后改写每个替换方法类的forwadInvocation的实现进行拦截,如果拦截到的Invocation的selctor转化成JPSelector能够响应,说明是一个替换方法,则从Invocation中取参数后调用JPSelector的IMP;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static void JPForwardInvocation(id slf, SEL selector, NSInvocation *invocation)
{
    NSMethodSignature *methodSignature = [invocation methodSignature];
    NSInteger numberOfArguments = [methodSignature numberOfArguments];
    
    NSString *selectorName = NSStringFromSelector(invocation.selector);
    NSString *JPSelectorName = [NSString stringWithFormat:@"_JP@", selectorName];
    SEL JPSelector = NSSelectorFromString(JPSelectorName);
    
    if (!class_respondsToSelector(object_getClass(slf), JPSelector)) {
      ...
    }
    
    NSMutableArray *argList = [[NSMutableArray alloc] init];
    [argList addObject:slf];

    for (NSUInteger i = 2; i < numberOfArguments; i++) {
      ...
    }
    
    //获取参数之后invoke JPSector调用JSFunction的实现
    @synchronized(_context) {
        _TMPInvocationArguments = formatOCToJSList(argList);

        [invocation setSelector:JPSelector];
        [invocation invoke];
        
        _TMPInvocationArguments = nil;
    }
}

Patch现场复原的补充

Patch现场恢复的功能主要用于连续更新脚本的应用场景。由于IOS的App应用按Home键或者被电话中断的时候,应用实际上是首先进入到后台运行阶段(applicationWillResignActive),当我们下次再次使用App的时候,如果后台应用没有被终止(applicationWillTerminate),那么App不会走appliation:didFinishLaunchingWithOptions方法,而是会走(applicationWillEnterForeground)。 对于这种场景如果我们连续更新线上脚本,那么第二次脚本更新则无法保留最开始的方法实现,另外恢复现场功能也有助于我们撤销线上脚本能够恢复应用的本身代码功能。

(1)WaxPatch的现场恢复

在Wax中通过wax_start(“init lua file”)启动框架并通过脚本替换类方法,通过wax_end()停止使用waxPatch环境。 Wax的官方维护版本中也没有现场恢复功能,替换方法的现场保存也是我们维护团队自行加入的,其实就是在替换方法或者新增方法的时候对原始现场(替换类/新增类+替换方法/新增方法)进行cache,当需要恢复patch现场的时候通过cache进行复原。 下面简单说一下wax中的现场保存和恢复功能:

首先是在方法替换的时候,记录替换类+替换方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static BOOL overrideMethod(lua_State *L, wax_instance_userdata *instanceUserdata){
  ...
  
  if(instImp) {
        if(!class_respondsToSelector(klass, newSelector)) {
            class_addMethod(klass, newSelector, prevImp, typeDescription);
        } else {
            class_replaceMethod(klass, newSelector, prevImp, typeDescription);
        }
        success = YES;
        
        NSDictionary *dict = @{@"class" : klass,
                               @"sel" : selector ? NSStringFromSelector(selector) : [NSNull null],
                               @"sel_objc" : newSelector ? NSStringFromSelector(newSelector) : [NSNull null], // objcXXXX
                               @"typeDesc" : typeDescription ? [NSString stringWithUTF8String:typeDescription] : [NSNull null],
                               @"identifier" : identifier
                               };
        addMethodReplaceDict(dict);
    } 
  
  ...
}
  

当Wax_end调用的时候恢复现场:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//调用wax_end
void wax_end() {
    wax_clear();
    [wax_gc stop];
    lua_close(wax_currentLuaState());
    currentL = 0;
}

//wax_clear()恢复现场

/// 重置所有被wax修改的方法和类
void wax_clear() {
    // methods rollback
    for (NSDictionary *dict in replacedMethodArray) {
        Class class = dict[@"class"];
        NSString *sel_str = dict[@"sel"];
        NSString *sel_objc_str = dict[@"sel_objc"];
        NSString *typeDesc = dict[@"typeDesc"];
        NSString *identifier = dict[@"identifier"];
        if (identifier) {
            [[LDAOPAspect instance] removeAnInterceptorWithIdentifier:identifier];
        }
        
        if (sel_str && ![sel_str isKindOfClass:[NSNull class]]
            && sel_objc_str && ![sel_objc_str isKindOfClass:[NSNull class]]
            && typeDesc && ![typeDesc isKindOfClass:[NSNull class]]) {
            SEL sel = NSSelectorFromString(sel_str);
            SEL sel_objc = NSSelectorFromString(sel_objc_str); // objcXXXX
            IMP imp = class_getMethodImplementation(class, sel_objc);
            class_replaceMethod(class, sel, imp, typeDesc.UTF8String);
        }
    }
    
    [replacedMethodArray removeAllObjects];
    [replacedMethodArray release];
    replacedMethodArray = nil;
    
    // class rollback
    for (NSDictionary *dict in modifiedClassArray) {
        NSString *className = dict[@"class"];
        NSInteger version = [dict[@"version"] intValue];
        Class class = NSClassFromString(className);
        class_setVersion(class, version);
    }
    [modifiedClassArray removeAllObjects];
    [modifiedClassArray release];
    modifiedClassArray = nil;
}

(2)JSPatch的现场恢复

为了跟Wax配合使用,本文在JSPatch添加了一样的调用方式和现场恢复功能;源码地址参考:

说明如下:

(1)在JPEngine.h 中添加了两个启动和结束的调用函数如下:

1
2
void js_start(NSString* initScript);
void js_end();

(2) JPEngine.m 中调用函数的实现以及恢复现场对部分代码的修改:主要是利用了替换方法和新增方法的cache(_JSOverideMethods, 主要是这个)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//处理替换方法,selector指回最初的IMP,JPSelector和ORIGSelector都指向未实现IMP
 if([JPSelectorName hasPrefix:@"_JP"]){
     if (class_getMethodImplementation(cls, @selector(forwardInvocation:)) == (IMP)JPForwardInvocation) {
         SEL ORIGforwardSelector = @selector(ORIGforwardInvocation:);
         IMP ORIGforwardImp = class_getMethodImplementation(cls, ORIGforwardSelector);
         class_replaceMethod(cls, @selector(forwardInvocation:), ORIGforwardImp, "v@:@");
         class_replaceMethod(cls, ORIGforwardSelector, _objc_msgForward, "v@:@");
     }
     
     
     NSString *selectorName = [JPSelectorName stringByReplacingOccurrencesOfString:@"_JP" withString:@""];
     NSString *ORIGSelectorName = [JPSelectorName stringByReplacingOccurrencesOfString:@"_JP" withString:@"ORIG"];
     
     SEL JPSelector = NSSelectorFromString(JPSelectorName);
     SEL selector = NSSelectorFromString(selectorName);
     SEL ORIGSelector = NSSelectorFromString(ORIGSelectorName);
     
     if(class_respondsToSelector(cls, ORIGSelector) &&
        class_respondsToSelector(cls, selector) &&
        class_respondsToSelector(cls, JPSelector)){
         NSMethodSignature *methodSignature = [cls instanceMethodSignatureForSelector:ORIGSelector];
         Method method = class_getInstanceMethod(cls, ORIGSelector);
         char *typeDescription = (char *)method_getTypeEncoding(method);
         IMP forwardEmptyIMP = getEmptyMsgForwardIMP(typeDescription, methodSignature);
         IMP ORIGSelectorImp = class_getMethodImplementation(cls, ORIGSelector);
         
         class_replaceMethod(cls, selector, ORIGSelectorImp, typeDescription);
         class_replaceMethod(cls, JPSelector, forwardEmptyIMP, typeDescription);
         class_replaceMethod(cls, ORIGSelector, forwardEmptyIMP, typeDescription);
     }
 }
 
 //处理添加的新方法
 else {
     isClsNew = YES;
     SEL JPSelector = NSSelectorFromString(JPSelectorName);
     if(class_respondsToSelector(cls, JPSelector)){
         NSMethodSignature *methodSignature = [cls instanceMethodSignatureForSelector:JPSelector];
         Method method = class_getInstanceMethod(cls, JPSelector);
         char *typeDescription = (char *)method_getTypeEncoding(method);
         IMP forwardEmptyIMP = getEmptyMsgForwardIMP(typeDescription, methodSignature);
         
         class_replaceMethod(cls, JPSelector, forwardEmptyIMP, typeDescription);
     }
 }

HotfixPatch的那些坑

WaxPatch之前被一些同事抱怨有不少坑,JSPatch在使用过程中也会遇到不少坑,所以虽然这两个框架现在虽然都能够做到新增可执行代码,但是将其应用到开发功能组件还不太可取。

比如说我在第一次使用JSPatch遇到了一个坑:(后面想单写一个博客收集一下我们团队使用Patch遇到的坑~~)

  • 在JS脚本改写派生类中未实现的继承类的 optional protocol方法时,tableView reload的时候不会调用JS的补丁方法,但是在tableView中显式调用可以调用替换的selector方法;另外如果在派生类中重写这个protocol方法,则可以调起;

先写这么多了,本来想写一下我们的patch管理方案,觉得没有什么可说了,就不写了~

基于ReactiveCocoa的MVVM探索

前言

一直觉得对于具体功能模块的设计模式来说,没有什么值得研究和讨论的。觉得iOS提供的MVC模式已经能够很好的利用到产品的开发中去,对于大多数开发人员来说,最熟悉最得心应手的设计模式莫过于此。

但是随着业务需求的膨涨、页面交互的体验优化、业务模式的逐渐丰富,我们一直习以为常的MVC模式中,某些Controller的UI交互代码+业务逻辑代码已经达到上千行的规模。当我们维护app、或进行新功能的扩充、或提供其他模块的调用接口时,复杂混乱的controller逻辑让我们头疼不已。

于是我们开始将Controlller的业务逻辑代码进行拆分,拆分部分逐渐形成了viewModel层,于是在现下移动客户端领域开始兴起了MVVM模式(V-VM-M)。随着FaceBook的“React Native“跨平台开发框架,以及之前就出现的ReactiveCocoa响应式编程模式的推出,MVVM设计模式在IOS移动端正逐渐被得心应手的开始使用。

本文在在学习了解React Native和ReactiveCocoa的基础上,对在IOS开发框架中如何采用MVVM模式进行了一定的探索和实践,并在实际的工程项目中如何利用MVC和MVVM做混合模式开发给出一定的建议,部分内容可能偏理论一点,不妥之处,望指教更正。

关于ReactiveCocoa和MVVM的学习资料:点击这里-Chrome书签

通过学习资料了解完ReactiveCocoa的基本概念之后,我们可以从下面一张图对ReactiveCocoa有一个整体理解:

核心概念是RACSignal,相当于一个信号管道,接收信号源的信号,将信号依次发送给订阅者,在RACSignal基础上我们在MVVM模式下用得比较多的是RACCommand,信号到来开始执行,每次执行产生一个回调Signal。 具体的理解可以参考学习资料,讲的很清楚。

目前最新版本的ReactiveCocoa开始支持Swift,要求最低发布版本为IOS8,所以我们可以使用支持IOS6的最高版本, 当接入ReactiveCocoa之后,在性能上会慢1~2倍,但不影响app的直接体验,另外在调试部分也需要程序员看懂信号的来源和出处,打断点进行调试。

1
2
3
4
5
6
7
8
9
10
11
12
13
platform :ios, '6.0'
inhibit_all_warnings!

workspace 'WTestReactiveCocoa'
xcodeproj 'TestReactiveCocoa'

target :TestReactiveCocoa do
    pod 'ReactiveCocoa', '~> 2.5'

    #设置pod target需要link的工程target
    link_with 'TestReactiveCocoa'
end

本文主要介绍通过ReactiveCocoa进行MVVM模式的开发实践,具体的应用模式如下:

一、关于view层和viewModel层的Binding

对于某个业务功能(这里指某个controller),从代码集来看,肯定是一堆系统原生view组件和自定义view组件在controller.view中的聚集。所有的叶子view组件节点都是以controller.view为根节点,作为其subview存在于controller中。所以view层其实可以看作是以controller.view为根节点的树状结构,负责view的创建、布局、和viewModel的binding、以及页面的交互效果(包括动画、跳转、切换等),只涉及到页面交互处理,不涉及业务逻辑的处理。

而对于viewModel来说,一般和controller是一一对应的,view层可以强依赖viewModel层,而viewModel层一定不能引用view层的任何对象。 view层中涉及业务逻辑处理的任何交互均需要通过signal信号告知viewModel层或者直接调用viewModel的command命令执行,同时view层也可以监听viewModel中定义的signal或者command执行发出的signal完成页面交互逻辑的处理。

具体处理过程

在controller初始化时,为该controller初始化一个对应的viewModel对象。这个viewModel可以只是个壳,viewModel中的具体属性对象可以通过前一个controller赋值初始化,也可以在当前controller中发出某个signal之后通过Model层提供的service进行初始化。

1
2
3
4
5
6
7
8
- (instancetype)init
{
    if (self = [super init]) {
        _fhcViewModel = [CPArenaFootHallContainerViewModel new];
        [self setupContent];
    }
    return self;
}

完成初始化之后,需要在viewDidLoad方法中在view组件创建之后,完成view层和viewModel层的binding。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)viewDidLoad
{
    [super viewDidLoad];
    [self setupContent];
    [self bindViewModel];
}

/**
 * 将controller中的view和ViewModel进行binding
 */
-(void) bindViewModel{
    //监控初始化hallModel command的执行
    @weakify(self);
    [[self.fhcViewModel.loadHallModel execute:@(YES)] subscribeNext:^(id statusCode){
      //to do something
    }];
 }

tip:

(1) 并不是所有的view组件都需要和viewModel绑定,通常如果view组件的显示属性是常量定义(如显示标题、某个业务组件的icon图片名称), 这些属性没有必要放入viewModel层,也就没有必要进行binding。

(2) 对于controller中跟viewModel中的对象息息相关的各个view对象(获取textField的输入值作为viewModel中某个operation的输入,点击某个按钮执行viewModel的某个command), 则需要在controller中进行绑定。

不同的情景其binding的方法也不一样:

  • 情景1:

如果subview是UIkit自带的view组件(如label,button, textfield, textview, imageView, alert, action sheet等等),则可以直接通过ReactiveCocoa直接进行view和viewModel之间的绑定。

ReactiveCocoa提供绑定category的的基本View组件包括:MKAnnotationView、UIActionSheet、UIAlertView、UIBarButtonItem、UIButton、UICollectionReusableView、UIControl、UIDatePicker、UIGestureRecognizer、UIImagePickerController、UIRefreshControl、UISegmentedControl、UISlider、UIStepper、UISwitch、UITableViewCell、UITableViewHeaderFooterView、UITextField、UITextView。

而我们平常经常用到的可能会有UITextField,UITextView、UIControl、UIButton、UIActionSheet、UIAlertView这几个,具体的使用方法在ReativeCoccoa的示例中可以查看。其封装核心是监控view组件的delegate方法或者selector方法执行,每当执行时发送一个执行信号;view层将该信号和viewModel层进行绑定,自己也可以监控这些信号完成某些交互行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)bindViewModel {
    self.title = self.viewModel.title;
    RAC(self.viewModel, searchText) = self.searchTextField.rac_textSignal;
    self.searchButton.rac_command = self.viewModel.executeSearch;
    RAC([UIApplication sharedApplication], networkActivityIndicatorVisible) = self.viewModel.executeSearch.executing;
    RAC(self.loadingIndicator, hidden) = [self.viewModel.executeSearch.executing not];
    
    [self.viewModel.executeSearch.executionSignals
     subscribeNext:^(id x) {
         [self.searchTextField resignFirstResponder];
     }];
}

  • 情景2:

情景2主要是针对subview是通过单独文件封装的view组件,如UITableView的自定义tableviewCell,某些在基础view组件上进行封装的view组件等。如果是新写这些view组件,可以考虑通过ReactiveCocoa的方式完成;如果是已经成型的组件,则需要根据具体情况看是否改造成响应式模式;

(1)如果组件中只绑定一两个viewModel中非集合类型的属性值,则直接通过开放view组件的属性值,在controller中完成binding; 具体的binding方法和情景1类似。

(2)如果组件中有较多的基础view组件,且都和viewModel相关,则考虑为该view组件定义一个view对象,并作为view组件的属性。controller将view组件的viewModel属性和controller对应的viewModel相应属性绑定,当controller中对应的viewModel发生变化,将会把变化传递给view 组件,在view组件中根据viewModel的属性变化去改变具体的基础view组件显示值;

这个跟Reative Native的props属性传递的思想类似:对于一些component,通过属性传入,在componnet中可以通过这些props去对基础UI组件赋值,当传入属性值改变之后,对应component的相应UI组件的显示值也会发生变化。

在引入ReactiveCocoa之前,view组件和viewModel的对应如下所示:

1
2
3
4
5
6
7
8
9
10
- (void)setContent:(CPArenaMatchViewModel *)content
{
    if ([content.sps count] != 3 || [content.supportRates count] != 3) {
        return;
    }
    
    [self.winButton setHeaderText:content.teamAName];
    [self.winButton setDetailText:[NSString stringWithFormat:@"胜 %@",content.sps[0]]];
    [self.drawButton setHeaderText:@"平"];
}

在引入ReactiveCocoa之后,具体viewModel和view组件的binding过程则根情景1种的binding类似。

  • 情景3:

对于比较复杂的系统view,如tableView、colletionView、pageView等,目前ReactiveCocoa中没有提供category进行绑定。

个人觉得对tableview整体进行封装没有必要,一方面对于app来说,大多数复杂的界面都是在tableview的基础上定制完成的,定制越多,封装就越没有意义;另外这些复杂的系统view组件一般提供了很好的扩展性和自刷新机制(ReloadData),如tableview的section、header view、footer view、tableviewcell等,这些扩展性就对应了情景2种提到的第二种情况。

对于这种情景的处理方法:我们可以通过传入一个viewModel对象给自定义的view或者cell,当页面刷新的时候,可以根据监控到的传入viewModel变化刷新界面;即使是非定制的view,则可以对view的属性直接通过controller对应的viewModel直接赋值;

截取了tableView:cellForRowAtIndexPath的绑定代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

    if ([moduleKey isEqualToString:kArenaHallDaily]) {
        
        static NSString *dailyIdentifier = @"dailyCell";
        CPArenaHallDailyCell *cell = [tableView dequeueReusableCellWithIdentifier:dailyIdentifier];
        if (cell == nil) {
          ...
        }
        
        //直接读取viewModel中的值进行view组件的赋值,当tableview reload的时候,重新读取一次
        [cell setContent:[self.fhViewModel.hallModel arenaHallDailyContent]];
        return cell;
        
    } 
  • 情景4:

而某些container-controller(tabController和NavigationController除外),在两个或者多个controller之间切换,controller.view中基本没有交互,交互主要在navigationBar上。NavigationBar的交互主要是controller跳转和切换,可能需要从Model中获取一些数据进行判定。

对于这种情景,我觉得没有必要通过MVVM模式进行处理,直接按照MVC模式处理即可,Model的获取通过Model层提供的APIService获取(单例),其跳转或者切换的controller中可以直接通过APIService获取之前已经初始化的Model对象;

tip

(1) 关于view的常量定义仍然属于View层,没有必要将其放进viewModel中(viewModel还只是保存view需要跟Model打交道,对其进行加工的部分);

(2) 在view层,对于无交互且无数据刷新需求的view组件不进行绑定; 对于有交互的view组件,如果交互不会对viewModel进行改变,也无需绑定;对于一些需要等待viewModel初始化完成才能进行数据显示的view组件,则可以考虑统一监控viewModel,当viewModel有更新的时候统一刷新各个view组件;

(3) 一个controller拥有一个viewModel, 比较复杂的view组件也可以拥有一个viewModel,但其viewModel的初始化在controller的viewModel中完成;

关于controller的简化和跳转

引入ReactiveCocoa有两个目的:

第一个:是在controller中一些交互比较频繁、状态逻辑较复杂的情况,可以通过ReactiveCocoa的Signal机制监听view组件的状态变化简化状态逻辑的判定。 比如说ReactiveCocoa的官方经典案例:用户名密码登录框;

第二个目的就是本文主要阐述的方向,通过引入ReactiveCocoa简化controller, 将controller中的交互逻辑和业务逻辑分开,形成有效的MVVM开发模式。在将MVVM模式应用的具体的业务组件工程中可能会引出下面一些问题:

1
2
1. controller之间的跳转到底应该放在view层还是viewModel层?
2. 什么代码可以从controller中分离,如何分离?

Controller间的跳转

在传统MVC模式下的controller跳转,需要在当前controller中import另外一个controller头文件,然后传入参数初始化controller,再调用navigation-push 或者 viewController-present, 相当于controller跳转是放到了view层。传统的MVC模式给我们带来的痛苦是无法对controller层进行单元测试,并且controlle中业务逻辑和交互逻辑混乱不堪,所以我们才希望通过MVVC模式来简化controller中的代码;

当我们将业务逻辑拆分到viewModel中,那么controller的跳转是继续留在view层还是将其拆分到viewModel层呢?我们来看一下分别放在两个层的优缺点:

  1. 如果继续放在view层:MVVM模式号称的去UI的测试也就无法去验证跳转逻辑,无法做完整性测试;并且每个controller的viewModel是孤立的,只能对其做单元测试;

  2. 如果直接拆分放到viewModel层,它就背离了我们之前的分层标准,viewModel层绝对不能依赖view层的任何对象;如果我们通过viewModel层提供的service去做跳转,在MVC模式和MVVM模式混合开发的工程中,则稍显复杂

所以本文觉得还是把controller的跳转继续放在view层;相当于通过view层的navigator去完成跳转,其实这跟React Native的跳转也是不谋而合的(在react Native中,每一个controller都被当作是navigator下的一个scene,每个scene跳转的时候都会保留对全局navigator的引用,直接在view层的代码中调用navigator.push完成)。另外controller的跳转对于整个app来说,也是属于view交互的定性。

交互逻辑和业务逻辑如何分离?

前文提到MVVM模式的主要工作就是将controller中的业务逻辑拆分出来,那在一个controller中如何定义业务逻辑,具体哪些代码需要拆分到viewModel中呢?前文也提到view层的主要工作是完成view的创建、布局、动画,以及交互和跳转处理。

view组件创建过程中,一些属性值需要在创建时通过参数传入,如果这些属性值是通过常量(如标题、按钮文字、alert提示文字、按钮的图片名称等),则不需要放到viewModel中;如果属性值是需要从Model中获取的数据(有些时候需要加工处理)进行初始化,则需要将其通过viewModel提供,一开始可以先传入一个空的viewModel,并建立view属性和viewModel的binding,当viewModel初始化完成或者更新的时候,通过signal更新view组件。另外在创建、布局view的时候可能需要一些model属性的判定,这些判断逻辑直接通过引用viewModel的数值即可。view层除开viewModel的绑定之外,只能读取viewModel中的值去初始化界面。

而交互逻辑和业务逻辑的主要交叉点出现在controller的生命周期和view组件的action操作中:

  1. 比如说contorller的生命周期,当controller初始化并push之后,会先跳转到viewDidload中初始化subview,然后在viewWillAppear中完成布局,并向viewModel或者model层请求数据。 或者在在生命周期中需要监听某些事件通知、执行某些定时器等,这些逻辑代码就应该放到viewModel中。

  2. 另外对于view组件的action操作,当touch事件发生之后,既要完成view的显示或者隐藏、或重新布局、或者更新数据,又需要向viewModel层发起某些操作,这些向viewModel发起的操作逻辑也必须放到viewModel中;

当我们把这些业务逻辑拆分出去之后,如何在某些交互或者某个生命周期状态发生时自动调用对应的业务逻辑呢?业务逻辑在viewModel中又是如何组织的呢?

业务逻辑在viewModel中通过RACCommand进行管理,当调起某个command的执行之后,首先完成业务处理,并通过signal(return value)通知调用者发起回调。

1
2
3
4
5
6
7
//执行添加leaveTimer
@weakify(self);
self.addLeaveTimerCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input) {
    @strongify(self);
    [self addLeaveTimer];
    return [RACSignal return:@(YES)];
}];

如果业务处理是异步的,则需要在viewModel中定义全局Signal(RACSubject),当异步业务处理执行完成或者错误的时候,通过RACSubject通知调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//初始化更新Signal,供view层监听
self.refreshResultSignal = [[RACSubject subject] setNameWithFormat:@"CPArenaFottHallVM-refreshResultSignal"];
    
    
/**
 * 重新刷新hallModel(hallModel其实应该是一个比较通用的ViewModel)
 */
-(void)refreshContent
{
    void (^ completion)(void) = ^{
        [self.refreshResultSignal sendCompleted];
    };
    
    //通过service重新load HallModel数据
    if(1){
        [self.refreshResultSignal sendNext:@"hello"];
        completion();
    }
}

    

生命周期状态的监听我们通过rac_signalforSelector去完成,某些view组件的action,则直接通过view组件的category,或者监听view组件的delegate回调来完成监听。也可以对这些信号进行组合、过滤、map、返回参数的封装,这些都是ReactiveCocoa提供的方法;

监听controller生命周期状态如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//当view层disappear时,通知viewModel层开始计时
[[self rac_signalForSelector:@selector(viewWillDisappear:)] subscribeNext:^(id x) {
    @strongify(self);
    [self.fhViewModel.addLeaveTimerCommand execute:nil];
}];


//监控view层的显示Signal
[[self rac_signalForSelector:@selector(viewWillAppear:)] subscribeNext:^(id x) {
    @strongify(self);
    [self.fhViewModel.refreshCheckCommand execute:nil];
}];

监听view组件的delegate回调如下:

1
2
3
4
5
6
7
8
//监听当前HeadView的Accessory的click动作
RACSignal *clickAccessorySignal = [self rac_signalForSelector:@selector(didClickAccessoryViewInHeaderView:) fromProtocol:@protocol(CPArenaHeaderViewDelegate)];
[[clickAccessorySignal map:^id(RACTuple *tuple) {
    return tuple.first;
}] subscribeNext:^(CPArenaHeaderView *headerView) {
  //to do something
}

tip:

  1. 将某些delegate方法通过signal监听之后,其实是可以删去这些delegate方法,但是编译器报警告,所以保留原有的delegate方法,只是方法实现为空;真正的方法实现放到Signal监听之中;

viewModel层和Model层之间的交互

在Model层,ReactiveCocoa提供如下基础对象signal改造的支持:NSArray、NSData、NSDictionary、NSEnumerator、NSFileHandle、NSIndexSet、NSInvocation NSNotificationCenter、NSObject、NSOrderedSet、NSSet、NSString、NSURLConnection、NSUserDefaults. 如果工程中的Model层也是通过响应式编程实现,这些category可能对Model层通知viewModel层有很大的作用,目前还没有对model层响应式编程仔细研究(这不是必要的),这里说一下Model层在MVVM模式的主要工作模式。

APIService的发起路径和过程

在MVC模式下,我们通常直接在controller中创建Model层提供的APIService去获取数据。而在MVVM模式中,相应APIService的请求会转移到ViewModel层,而ViewModel的初始化是根据Model层的访问完成的,其具体访问路径如下:

(1)当点击某个按钮或者发起初始化signal,点击动作或者创建会向viewModel发出一个signal执行RACCommand(RACCommand,执行 一次产生一个signal, 这个command在viewModel层定义和创建),当前view层会定监听RACCommand的执行,并根据返回的event实时更新view;

(2)viewModel接受这个signal:

  • 如果signal是创建ViewModel,则判断当前viewModel是否初始化,如果没有,则向Model层发起一个 initial operation;

  • 如果signal改变了viewModel中的值,viewModel的变化会自动通知binding的view组件更新;此外如果viewModel的改变和Model层有关联,则向Model层发起一个update operation;

  • 当前viewModel向Model发起operation之后,当前viewModel需要定制监控发起的这个signal,并将后台返回的signal信号进行处理,或更新viewModel,或向view层监控的signal发送各个operation的完成事件;

(3)Model层不仅仅是实体对象的定义,还包括APIService的实现,APIService通过向后台发送request请求,当请求返回的时候能够根据实体定义初始化实体对象,并在Model层持有(或进一步持久化,保存为coredata或者db中);

  • 当Model层接受到viewModel(MVVC)或者controller(MVC)的 initial operation时,查看Model层持有的对象,如果存在直接返回,如果不存在,则通过APIService向后台或者本地持久化发出请求,并将请求状态和结果push给ViewModel层或者controller层;

  • 当Model层接收到update operation时,一般是先通过APIService发送update operation,根据operation的结果操作本地持久化对象(或者重新拉取一遍,或者直接在update operation中返回数据对原有持久对象进行更换);

关于Model和网络请求的生命周期

关于Model层什么时机进行初始化,Model层持有对象的生命周期,以及网络请求的生命周期?

  • Model层的初始化,分为不同的场景,初始化的时机也不一样:

(1) 如果Model是全局需要的,如UserModel,则可以在appdelegate中直接初始化;

(2) 如果是MVC模式,则直接在controller中直接调用APIService初始化;

(3) 如果是MVVM模式,则通过调用viewModel中进行初始化;

  • 生命周期:

(1) 如果是全局Model,则通过单例Service进行保持;

(2) 如果是跟controller保持一致的,则通过普通service进行初始化;

  • 网络请求的生命周期:

(1) 如果是单例的service,网络请求的生命周期肯定小于单例,service持久化的Model对象也会一直存在;

(2) 如果是非单例service,service持久化的Model对象随着service的dealloc而销毁, 网络请求的生命周期也会被service cancel掉;仍然小于service;