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

测试驱动开发

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!😄😄😄

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容