iOS链式语法数据绑定轻量级框架实践

一、需求背景

1、现状

当前组件化开发中,经常会用到MVVM设计模式,它促进了UI代码与业务逻辑的分离,一定程度解决viewController臃肿问题,但也使得数据绑定变得复杂,很多情况下需要我们手动绑定数据和刷新界面,经常要写一堆零散的数据绑定业务代码。

关于数据绑定的复杂度问题,我们完全可以使用ReactiveCocoa框架(一个典型的函数响应式编程框架)解决,这里不做深入了解,它虽然很好很强大,但对于组件化开发来说还是供过于求,目前我们仅仅需要一个轻量级的数据绑定框架。

2、目标

自己维护一个轻量级的数据绑定开源框架,例如CRDataBind(Chain Response Data Bind),它的接口调用支持链式语法,并通过响应式编程快速实现数据绑定更新。

(本方案主要以老虎机抽奖demo对其进行实践和分析,所用数据绑定框架原出自:github.com/shidavid/DV…,非常感谢作者的贡献!)

二、解决方案及亮点

1、方案概述

  • 使用链式编程,支持多项绑定,支持单向/双向数据流;
  • 支持过滤,某些条件下不更新绑定的数据;
  • 支持数值与字符串自动转换,以及自定义数据接收格式;
  • 只要支持KVC的对象都能实现数据绑定,不限定只能View和ViewModel;
  • 无需依赖第三方,无需手动解绑,当目标对象内存释放时,CRDataBind自动解绑和释放。

2、问题难点

1)、如何通过链式语法一次绑定多个对象?

2)、如何通过响应式编程实现数据绑定?

3)、如何实现自动解绑?

3、分析过程

1)、链式语法

在Objective-C中,我们调用方法一般使用“[]”,简单的调用看起来过得去。但如果叠加很多层调用后,便不易阅读,常有漏掉某个“]”或“[”报错情况。

链式语法的核心是点语法。为了让OC在进行多层方法调用时,能够优雅和清晰的展示代码,我们可以借鉴Swift、Masonary等的点语法形式。

示例:

    // Swift:获取文件路径
    let path: String = Bundle.main.path(forResource: "image",     ofType: "jpg")!  

    // Objective-C:Masonary布局更新
    [self.growingButton mas_updateConstraints:^(MASConstraintMaker *make) {
        make.center.equalTo(self);
        make.width.equalTo(@(self.buttonSize.width)).priorityLow();
        make.height.equalTo(@(self.buttonSize.height)).priorityLow();
        make.width.lessThanOrEqualTo(self);
        make.height.lessThanOrEqualTo(self);
    }];

点语法的关键是block,可借鉴Swift闭包的使用。它的特殊在于其本身可以帮助方法进行参数传递,并返回数据,这样我们便可以让方法不断返回实例本身,继续调用实例方法。

示例:

    /**绑定3
     model.winCode <---> winCodeLb.text <---> winCodeTF.text
     增加fiter过滤中奖号大于3位数的响应
     */
    CRDataBind
    ._inout(self.lotteryVM.currentLottery, @"winCode")
    ._out(self.winCodeLb, @"text")
    ._inout_ui(self.winCodeTF, @"text", UIControlEventEditingChanged)
    ._filter(^BOOL(NSString *text) {
        // 增加过滤:中奖号码不超过3位数,界面上无法配置大于3位数
        return text.length <= 3;
    });

2)、响应式编程实现数据绑定

响应式编程是一种面向数据流和变化传播的编程范式,数据的输入输出(in&out)是关键,绑定-响应-刷新。数据inout的形式有:普通对象如target.property = value;UI对象如textField.text响应EditingChanged等等。

设想在同一个chain(响应链)中,我们需要一个观察者,观察者通过弱引用缓存所观察对象。然后,监听普通对象,可以使用KVO;监听UI对象时,绑定对应UI事件。那么chain上所观察的某个对象属性变化时,我们就可以遍历所有观察对象通过KVC(setValue:forkey:)进行更新操作。

3)、实现自动解绑

经过上面的分析,我们基本能实现接口的调用和实际数据绑定。接下来思考下:既然有绑定过程,那么对应的解绑也应该提供,而且最好是自动解绑,不需要外部手动去调用解绑和释放缓存。

应该如何触发解绑过程?比如target是进行数据绑定的对象,那么正常逻辑是target释放了,或者主动调用才进行解绑操作。我们需要捕获对象释放,现成的方式是利用dealloc方法,但我们的目的是自动解绑,所以不应在绑定的所有外部对象dealloc中调用解绑。于是我们可以考虑为所有target实现一个NSObject分类,并通过runtime关联一个targetModel,当target释放后,model也跟着释放,此时我们便可以在targetModel的dealloc中调用unbindWithTarget:进行解绑和释放缓存的操作。

三、详细设计

1、类图

image

2、代码原理剖析

1)、A 与 B 双向数据绑定,Ain数据变化更新Aout、Bout数据,Bin同理。

image

2)、有时候 A 与 B 双向绑定,B 与 C 双向绑定,其实相当于 A、B、C 一起绑定在一条数据链Chain上,每当有一个in数据变化,发送新数据到C

hain上,再由Chain更新所有的out数据。

这样实现单向/双向数据流。

3)、利用KVO和UI(addTarget:)事件,数据链就相当于Obverse,每个Observer用一个ChainCode标记,Observer观察每个in数据变化,并更新到所有out数据。

4)、主要对外接口阐述

链式语法调用的API,必须以 _inout 或 _in 开头(肯定要有数据in来源,不然后续也没意义),后面的绑定顺序可随意,不影响绑定结果。

  • _inout 发送+接收数据
  • _in 只发送数据
  • _out 只接收数据
  • _cv 进行自定义数据转换后再返回
  • _filter 条件过滤
  • _out_key_any 绑定自定义事件
  • _out_not 接收的数据取反再返回

具体接口如下:

#pragma mark - 双向绑定
+ (DataBindBlock)_inout;
+ (DataBindUIBlock)_inout_ui;
+ (DataBindConvertBlock)_inout_cv;
+ (DataBindUIConvertBlock)_inout_ui_cv;

- (DataBindBlock)_inout;
- (DataBindUIBlock)_inout_ui;
- (DataBindConvertBlock)_inout_cv;
- (DataBindUIConvertBlock)_inout_ui_cv;

#pragma mark - 单向绑定-发送(数据更新,只发送新数据,不接收)
+ (DataBindBlock)_in;
+ (DataBindUIBlock)_in_ui;

- (DataBindBlock)_in;
- (DataBindUIBlock)_in_ui;

#pragma mark - 单向绑定-接收(数据更新,只接收新数据,不发送)
- (DataBindBlock)_out;
- (DataBindConvertBlock)_out_cv;
- (DataBindBlock)_out_not;
- (DataBindKeyAnyOutBlock)_out_key_any;

#pragma mark - 过滤
- (DataBindFilterBlock)_fil

四、使用示例

设置数据绑定一般放在胶水层(ViewController)中进行,具体可结合自身设计模式灵活运用。

导入头文件:

#import "CRDataBind.h"

进行单向/双向数据绑定(label-只接收数据,model-即发送也接收数据响应):

    /**绑定
     model.winRate <---> rateLb.text <---> rateSlider.value
     */
    CRDataBind
    ._inout(self.lotteryVM.currentLottery, @"winRate")
    ._inout_ui(self.rateSlider, @"value", UIControlEventValueChanged)
    ._out(self.rateLb, @"text");

根据条件过滤,未达到条件不处理响应:

    /**绑定
     model.winCode <---> winCodeLb.text <---> winCodeTF.text
     增加fiter过滤中奖号大于3位数的响应
     */
    CRDataBind
    ._inout(self.lotteryVM.currentLottery, @"winCode")
    ._out(self.winCodeLb, @"text")
    ._inout_ui(self.winCodeTF, @"text", UIControlEventEditingChanged)
    ._filter(^BOOL(NSString *text) {
        // 增加过滤:中奖号码不超过3位数,界面上无法配置大于3位数
        return text.length <= 3;
    });

自定义数据接收格式:

    /**绑定
     model.sn <---> snLb.text <---> self.view.backgroundColor
     其中backgroundColor需要转换输出格式
     */
    CRDataBind
    ._inout(self.lotteryVM.currentLottery, @"sn")
    ._out(self.snLb, @"text")
    ._out_cv(self.view, @"backgroundColor", ^UIColor *(NSNumber *num) {
        NSInteger index = num.integerValue % kBGColors.count;
        return kBGColors[index];
    });

绑定自定义事件:

   /**绑定
     model.isWin <---> isWinLb.text <---> self.isWin
     增加外部自定义事件,中奖后让抽奖号码闪烁
     */
    __weak __typeof(&*self) weakSelf = self;
    CRDataBind
    ._inout(self.lotteryVM.currentLottery, @"isWin")
    ._out(self.isWinLb, @"text")
    ._out_key_any(@"202122", ^(NSNumber *num) {
        weakSelf.isWin = num.boolValue;
        NSLog(@">>>在setIsWin:中触发中奖时号码闪烁,iswin = %d", weakSelf.isWin);
    });

五、成效举证

针对本案制作了CRDataBindDemo,它是一个老虎机摇号抽奖程序,通过MVVM + CRDataBind链式响应编程,快速地完成了多个带界面交互的数据绑定业务。

1、demo效果

地址:github.com/stkusegithu…

主要数据绑定链有:

  • model.sn <---> snLb.text <---> self.view.backgroundColor(期号递增显示不同背景色)
  • model.winRate <---> rateLb.text <---> rateSlider.value(滑动slider改变中奖率)
  • model.code <---> codeLb.text(抽奖后显示抽奖号码变化)
  • model.winCode <---> winCodeLb.text <---> winCodeTF.text(设置下一期中奖号)
  • model.isWin <---> isWinLb.text <---> self.isWin(显示释放中奖,播放数字闪动动画)
image
image

2、成效说明

比如demo中,需要配置老虎机下一期中奖号码时,在未使用CRDataBind前的业务代码书写如下:

- (void)setupBind {
    // 绑定textField编辑事件
    [self.winCodeTF addTarget:self    action:@selector(winCodeTFdidEdittingChanged:) forControlEvents:UIControlEventEditingChanged];

    // 未知的地方调用
    self.lotteryVM.currentLottery.winCode = @"222";
    [self freshWinCodeUI];
}

- (void)winCodeTFdidEdittingChanged:(UITextField *)textField {
    if (textField.text.length > 3) {
        textField.text = [textField.text substringToIndex:3];
        return;
    }
    self.lotteryVM.currentLottery.winCode = self.winCodeLb.text = textField.text;
}

- (void)freshWinCodeUI {
    // 刷新界面
    NSString *winCode = self.lotteryVM.currentLottery.winCode;
    self.winCodeLb.text = self.winCodeTF.text = winCode;

可以看出上面比较零散和繁琐。再看看当我们使用CRDataBind后,是不是变得干净清爽多了:

- (void)setupBind {
    /**绑定
     model.winCode <---> winCodeLb.text <---> winCodeTF.text
     增加fiter过滤中奖号大于3位数的响应
     */
    CRDataBind
    ._inout(self.lotteryVM.currentLottery, @"winCode")
    ._out(self.winCodeLb, @"text")
    ._inout_ui(self.winCodeTF, @"text", UIControlEventEditingChanged)
    ._filter(^BOOL(NSString *text) {
        // 过滤:中奖号码需小于3位数
        return text.length <= 3;
    });
}

六、核心代码范围

代码位于目录 CRDataBindDemo/CRDataBindDemo/CRDataBind/下

---CRDataBind

+---CRDataBindDefine.h

+---CRDataBind.h

+---CRDataBind.m

+---CRDataBindObserverManager.h

+---CRDataBindObserverManager.m

+---CRDataBindObserver.h

+---CRDataBindObserver.m

+---NSObject+DataBind.h

+---NSObject+DataBind.m

+---CRDataBindObserverModel.h

+---CRDataBindObserverModel.m

+---CRDataBindTargetModel.h

+---CRDataBindTargetModel.m

推荐文章

iOS 架构
iOS获取调用链
iOS架构模式(代理,block,通知,MVC,MVP,MVVM)

视频资料

iOS_架构模式

如果您觉得还不错,麻烦在文末 “点个赞” 或者 下方评论 ,谢谢您的支持
查看原文

推荐阅读更多精彩内容