基于Redux思想编写高度可测试的iOS代码

作者: JimmyOu | 来源:发表于2017-08-03 13:33 被阅读285次

    测试驱动开发

    1.什么是测试驱动开发?
    基本思想就是在开发功能代码之前,先编写测试代码,然后只编写使测试通过的功能代码,从而以测试来驱动整个开发过程的进行。这有助于编写简洁可用和高质量的代码,有很高的灵活性和健壮性,能快速响应变化,并加速开发过程。
    我并不是说以后写业务,都要先写单元测试,然后写完单元测试后开始写业务。这会拉长开发周期,再说老板也不答应呀。但我一直认为高度可测试的代码的可维护性更强。所以可以在平时写代码前,多想想为自己的代码添加单元测试麻烦吗,是不是还有可测试性更高的写法。
    但往往我们在给自己的App添加单元测式,如果在写代码的时候没有过多的思考,写了很多胶水代码。编写写单元测试会变得很困难。无论是UI测试还是功能测试。如果改UI的代码散落各处,一个方法受状态的影响太多。
    而且状态可能来自服务器(http请求的回调),用户操作(Target - Action),一个方法依赖的外部状态太多了,要写单元测试得用一些测试库如ocmork,来模拟一些具体的依赖类,模拟相应或者返回,而且还的配合用户的点击状态。那么写一个函数的测试变的很复杂。而且一个VC如果很多都由这种代码组成,重构困难,加功能困难,需要改时候容易牵一发动全身,这个类也变得难以维护。

    2.设计阶段多思考可测试性
    如果一个模块(大模块或者一个MVC模块)变得容易测试,覆盖范围广,可维护性一定强。对于一个函数或者方法,怎么样才比较容易测试,测试无非就是模拟输入,验证输出。如果一个方法和输入和输出明确,类似于y= f(x),那么我们只要模拟x,验证y。就可以完成这个函数的单元测试。而这个f(x)的内部实现不会依赖于函数外部的参数,也就是说这个f(x)是独立的,没有副作用的函数。这个理想的函数有纯函数的一些特性。那么什么是纯函数?

    3.纯函数
    指一个函数如果有相同的输入,则它产生相同的输出,一旦输入给定,那么输出则唯一确定,没有负作用。比如y = sin(X)。
    像我们平时写的[someInstance method];这样的函数由于不存在输入和输出,如果内部实现还对外部的结构参数做一些改变,就很难直接测试这个函数。函数内部如果对参数意外的变量或者状态进行改变,这个就是这个函数的副作用(side effect),也可以说这个函数对其他变量的依赖性强,而且单看这个函数本身,还预见不了这个改变,依赖被函数所屏蔽了。这是一个容易引起bug的潜在因素。

    4.纯函数特点
    先偏离App,纯函数编程有以下特点:
    4.1.函数可以被当成参数和output。
    4.2.函数的结果只受函数参数影响,不依赖于定义在函数外的变量,对于特定输入有特定输出
    4.3.依赖于不可变数据结构
    4.4.由于计算是透明的,任何时候执行产生相同结果,可以推迟计算,知道需要的时候。lazy load 或者 swift的copy on write
    4.5利用范型进行高度抽象成功能性程序。
    4.6.函数为一等公民,可以作为参数和返回值。而且还可以延迟执行。

    其实Swift是一个很好实践函数式编程的一门得力语言,但我们今天不讲swift的函数式编程,如果对Swift的函数式编程思想感兴趣,可以参考objc.io上的advanced-swiftfunctional-swift,我这里有中文的译本

    5.实践
    5.1 如果你们和我一样现在还在用OC,其实作为一个纯面向对象的编程语言,但编程的时候以函数式思想编程还是有借鉴意义的。可以在这里找到具体例子demo。在介绍具体的代码前,我想先介绍以下Redux:

    Redux由Dan Abramov在2015年创建。是受2014年Facebook的Flux架构以及函数式编程语言Elm启发。Redux因其简单易学体积小在短时间内成为最热门的前端架构。

    它有以下几个核心概念:

    Action:简单地,Actions就是事件。用户的操作,网络的回调,Actions传递来自(用户接口,内部事件比如API调用和表单提交)的数据给store。store只获取来自Actions的信息。
    Store:Store对象保存应用的状态并提供一些帮助方法来存取状态,分发状态以及注册监听。全部state由一个store来表示。任何action通过reducer返回一个新的状态对象。这就使得Redux非常简单以及可预测。
    Reducer:在Redux中,reducer就是获得这个应用的当前状态和事件然后返回一个新状态的函数。(Action,State) ---> State
    State:应用的状态,决定着应用的行为和输出。

    对于 app 而言,我们总是会和一定的用户输入打交道,也必然会需要按照用户的输入和已知状态来更新 UI 作为“输出”。这个状态我们可以抽象成State,用户的输入或者其他能够改变状态的行为我们抽象为Action,那我们需要写自己的Reduce函数。设计Reduce函数的时候最好输入给一个state,输出一个全新的newState,它们是不同的对象,而不仅仅只是在同一个对象的基础上进行改变,这样在订阅方才可以明确知道state是否发生改变。

    reducer(Action,State) -> State
    

    我们还需要一个State来驱动变化,所以我认为State结构内部可以引用一些驱动用户行为或者UI变化的数据源,最好确保 State 中每个节点都是 Immutable 的,这样将确保 State 的消费者在判断数据是否变化时,只要简单地进行引用比较即可。

    我们需要Store来存储State,订阅观察者,给State派发Action,所以一个Store可能抽象成这样

    @interface Store : NSObject
    //当前状态
    @property (nonatomic, strong,readonly) id<StateType> state;
    //初始化方法,接受一个reducer和初始状态
    - (instancetype)initWithReducer:(Reducer )reducer
                       initialState:(id<StateType>)state;
    //订阅一个观察者,State发生改变通知观察者
    - (void)subscribeNext:(SubscribeBlock)subscriber;
    //取消订阅
    - (void)unsubscribe;
    //给State 派发Action
    - (void)dispatch:(id<ActionType>)action;
    
    @end
    

    对于Action,生产者给Store dispatch Action。我们将Action分为同步的或者异步的,同步的Action通过Reduce产生新的State驱动subscriber,异步的Action通过Reduce,这时候并不产生新的state,而是在回调中再向Store dispatch newAction ,再产生新的State后才驱动subsriber。
    数据的流动就变成这样:

    数据传递.png

    解释:Store会持有State和reducer,外界如果想要触发新的State只有通过向Store派发Action,Store拿到Action和当前的State,会尝试通过Reudcer产生一个新的State,如果这个Action是同步的,那么reduce可以立即产生新的有效的State,然后通知订阅者,订阅者根据最新的State来决定UI的样式。如果Action是异步的,reduce不会立即产生一个newState,而是在异步操作的回调中给Store派发一个新的同步的Action。外界任何其他角色不直接改变UI,UI是由唯一的State所决定。这样要测试这部分的业务,我们只要在给Store派发可预见的Action,然后在Subscriber中检测输出。这套逻辑本身没有依赖其他任何的UI状态,所以单元测试变得简单。

    看了这么多抽象的逻辑我们看具体的demo

    这是一个查询省的一个demo,跳转后,会用coreData记录下查询记录,搜索部分输入省名还可以进行查询。

    DateFlow.gif

    我们看下Store类的结构,Store初始化时候需要intialState和Reducer函数,reducer要从外面传入的原因是,reducer要操作具体的State,这个State必定和业务绑定。为了解耦。值得一提的就是这个dispatch方法,store将订阅者放到一个array容器里,接受到异步action的时候reducer会返回nil,我们就不通知订阅者,否则执行array的blocks

    @interface Store : NSObject
    
    @property (nonatomic, strong,readonly) id<StateType> state;
    
    - (instancetype)initWithReducer:(Reducer )reducer
                       initialState:(id<StateType>)state;
    
    - (void)subscribeNext:(SubscribeBlock)subscriber;
    - (void)unsubscribe;
    - (void)dispatch:(id<ActionType>)action;
    
    @end
    @implementation Store
    ...
    - (void)dispatch:(id<ActionType>)action {
        id<StateType> previousState = _state;
        id<StateType> nextState = self.reducer(previousState,action);
        if (nextState) {
            self.state = nextState;
            if (self.subscribers.count > 0) {
                __weak __typeof(self)weakSelf = self;
                [self.subscribers enumerateObjectsUsingBlock:
          ^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                    @synchronized (weakSelf) {
                        SubscribeBlock block = (SubscribeBlock)obj;
                        block(previousState,nextState);
                    }
                }];
            }
        }
    }
    @end
    ...
    

    在ViewController里面我们定义了State和Action的数据结构,当然也可以将这两部分抽出来放在另一个service类中,但我这里就这么做了。

    @interface State :NSObject<StateType,NSCopying>
    @property (nonatomic, copy) NSArray *cities;
    @property (nonatomic, copy) NSString *text;
    @property (nonatomic, copy) NSArray *histories;
    
    @end
    @implementation State
    
    - (id)copyWithZone:(NSZone *)zone {
        State *copy = [[[self class] allocWithZone:zone] init];
        copy.cities = self.cities;
        copy.text = self.text;
        copy.histories = self.histories;
        return copy;
    }
    @end
    
    

    我认为State就管理着数据源,因为State决定着程序的行为和UI的样式,而一般这些都是一些特定的数据所驱动的。这里整个demo的数据源有,所有省份(citites),搜索的文字(text)和历史记录(history)三部分组成,注意这里用的都是不可变得数据结构,state遵循了NSCopying协议,因为经过reducer的后的state和之前的state需要是两个不同的State。

    对于Action,区分了异步和同步的Action,它们在Reducer中的处理不一样。

    typedef NS_ENUM(NSUInteger, Action_Type) {
        UpdateText_Action,
        AddCities_Action,
        AddHistories_Action,
        
        //异步command
        FetchCities_Action,
        FetchHistories_Action,
        FetchAssociate_Action,
        ClearHistory_Action,
    };
    @interface Action :NSObject<ActionType>
    
    @property (nonatomic, assign) Action_Type actionType;
    @property (nonatomic, strong) id associateValues;
    + (instancetype)actionWithActionType:(Action_Type) type values:(id)associateValues;
    
    @end
    
    

    我们看这个重要的Reducer是如何被定义的,对于收到同步的AddHistories_Action,我们设置属性后返回新的State,对于异步的FetchHistories_Action,我们在回调中再发起一个新的同步的Action。

    - (Reducer )reducer {
        __weak __typeof(self)weakSelf = self;
        Reducer reducer = ^(id<StateType> state, id<ActionType>action){
            State *previousState = (State *)state;
            State *currentState = [previousState copy];
            switch (action.actionType) {
    .....省略一些代码
                case AddHistories_Action:
                {
                    id associateValue  = action.associateValues;
                    currentState.histories = associateValue;
                    break;
                }
                case FetchHistories_Action: {
                    [FetchData fetchHistories:^(NSArray *data, NSError *error) {
                        Action *action = [Action new];
                        action.actionType = AddHistories_Action;
                        action.associateValues = data;
                        [weakSelf.store dispatch:action];//2
                    }];
                    currentState = nil;
                    break;
                }  
               default:
                    break;
            }
            return currentState;
        };
        
        return reducer;
    }
    
    

    我们在viewDidload中我们1.初始化State,2.初始化Store,3.绑定订阅者,4.发起查询省的一个Action

    - (void)viewDidLoad {
        [super viewDidLoad];
        // Do any additional setup after loading the view, typically from a nib.
        
        self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"clearHistory"
                             style:UIBarButtonItemStylePlain 
                            target:self 
                            action:@selector(clearHistory)];
    
          [self.textField addTarget:self 
                             action:@selector(changed:) 
                     forControlEvents:UIControlEventEditingChanged];
        
        //1.初始化State
        State *initialState = [[State alloc] init];
        //2.初始化Store
        _store = [[Store alloc] initWithReducer:self.reducer initialState:initialState];
    
        __weak __typeof(self)weakSelf = self;
        [_store subscribeNext:^(State *old ,State *new) {
        //3.绑定订阅者
            [weakSelf stateDidChangeWithNew:new old:old];
        }];
    //4.发起查询省的一个Action
        Action *fetchCitiesAction = [Action actionWithActionType:FetchCities_Action values:nil];
        [_store dispatch:fetchCitiesAction];//3
        
    }
    

    那么在订阅者受到通知后,我们就可以根据newState和oldState来决定UI样式了,这样我们就把对UI的管理集中在了一处,外界只有通过向Store发送Action的形式才能改变State,而State又是唯一决定UI的元素。那么代码的逻辑是不是看起来就比较清晰了。

    - (void)stateDidChangeWithNew:(State *)new old:(State *)old{
        
        NSLog(@"old = %@,new = %@",old.description,new.description);
        
        if (old.cities == nil || new.cities != old.cities) { 
    //这里比较指针就好,因为经过reduce的是两个不同的state,而且属性都是不可变的。
            NSIndexSet *set = [[NSIndexSet alloc] initWithIndex:CitiesSection];
            [self.tableView reloadSections:set withRowAnimation:UITableViewRowAnimationFade];
        }
        
        if (old.histories == nil || new.histories != old.histories) { 
    //这里比较指针就好,因为经过reduce的是两个不同的state,而且属性都是不可变的。
            NSIndexSet *set = [[NSIndexSet alloc] initWithIndex:HistorySection];
            [self.tableView reloadSections:set withRowAnimation:UITableViewRowAnimationFade];
        }
    //    [self.tableView reloadData];
        //update title
        if (new.text == nil) {
            self.title = @"省";
        } else {
            self.title = new.text;
        }
    }
    

    总结

    其实这一套理论和语言本身无关,和UI也没有关系,更像是一种设计思想,我们可以把它用在任何无关UI的地方,有点向游戏设计中的状态机的设计思路。但无论怎样,这样的代码确实比习惯的胶水代码可测试性更强。如果在Demo中我们需要添加一个新的逻辑,我们只要在Action中添加一个新类型,State里面加一个新的数据源,在reduce里面处理,然后在stateDidChangeWithNew:old:处理,代码的逻辑依然清晰可见。如果喜欢请点个赞,或者star,Have Fun!😄😄😄

    相关文章

      网友评论

        本文标题:基于Redux思想编写高度可测试的iOS代码

        本文链接:https://www.haomeiwen.com/subject/bxpijttx.html