XKVO - iOS 轻量级通信工具,一对多的Block信号监听,使用Block来封装KVO

1,288 阅读8分钟

前言

一直想找一个方便使用的可以替代消息通知的通信工具,可以避免再编写繁琐的 Delegate 接口。偶然在项目中接触了 RAC框架(ReactiveCocoa),响应式编程 + 链式编程,功能的确很强大,但是也遇到过很多问题,踩过很多坑,每次解决成本都很高,学习成本太高,需要掌握很多细节特性才能用好。而且 RAC 属于大型框架,对项目代码侵入性很大,学习成本和维护成本都非常高。但是一直找不到一个简单的轻量级通信工具,于是自行编写了一个小工具 XKVO

如果不想继续使用 RAC 这种大型框架,又想从繁重的通信接口中解脱出来,那么本文描述的XKVO工具或许就能解决你的问题了。

XKVO介绍

轻量级通信工具 使用 Block 的方式实现一对多的事件通知,使用Block封装系统KVO方法,让监听事件与属性变化变得非常简单。

特点

  • 非常轻量,一共只有 200 行代码
  • 运行时不会出现冗长的 Stack 调用
  • 事件监听者销毁后,XKVO 持有的 Block 会自动释放,不产生内存泄漏
  • 支持一对多的通信

XKVO 分为两个部分,准确的说,其实只有两个类:

  1. XSignal
    XSignal - 轻量级简易信号通知工具,使用 Block 来代替 Delegate 与 Notification,支持一对多通信。

    信号-Block监听管理, 使用时创建一个 XSignal 对象,监听者拿到这个对象,向该信号注册监听 Block,一个信号对象(XSignal)可以注册多个监听者,当有事件发生的时候,信号对象的 Owner 调用 signal.emit(id) 发送信号,各监听者便能收到通知,先注册先到达。

    注册监听时,会返回一个 XSignalMonitor 对象,用于中途取消监听,调用其 close 即可。

  2. XKVO
    使用 XSignal 来封装系统的 KVO 方法,使用者可以使用 Block 来监听 KVO 的回调,简单易用。

用法举例

安装教程

1.  在 Podfile 中引入 XKVO: 
    pod 'XKVO', :git => 'https://gitee.com/fireice2048/xkvo.git', :branch => 'master'
2.  执行 pod install
3.  #import <XKVO/XKVO.h>

或者,将代码下载下来,从中找到 XKVO 目录下四个文件,XKVO.h/XKVO.m,XSignal.h/XSignal.m 拷贝并加入到工程中,然后 #import "XKVO.h" 即可使用。

KVO属性监听

  1. #import <XKVO/XKVO.h>
  2. 添加KVO监听代码
@interface ViewController ()

@property (nonatomic, assign) NSInteger testCount;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    [XKVO xkvo_addObserver:self object:self property:@"testCount" changedBlock:^(XKVOValue * _Nonnull kvoValue) {
        NSLog(@"监听到 testCount 属性变化 %@ ==> %@", kvoValue.oldValue, kvoValue.changedNewValue);
    }];
    
    self.testCount ++;
}

@end

  1. 运行程序,会看到如下日志输出:
   监听到 testCount 属性变化 0 ==> 1
  1. 将监听代码添加 initialNotify:YES,
    [XKVO xkvo_addObserver:self object:self property:@"testCount" changedBlock:^(XKVOValue * _Nonnull kvoValue) {
        NSLog(@"监听到 testCount 属性变化 %@ %@ ==> %@", kvoValue.isInitialNotify ? @"是初始值" : @"", kvoValue.oldValue, kvoValue.changedNewValue);
    } initialNotify:YES];

运行程序,会看到如下日志输出:

   监听到 testCount 属性变化 初始值 0 ==> 0
   监听到 testCount 属性变化 0 ==> 1
  • initialNotify : 初始值是否回调block通知,默认为 NO。由于iOS的KVO在监听到变化前,拿不到即将变化的新值,所以没必要实现 priorNotify
  • kvoValue.isInitialNotify : YES 表示该次监听回调的是当前的值,并非变更事件,此时 kvoValue.changedNewValue 跟 kvoValue.oldValue 是相同的值
  • 当添加的observer对象销毁后,XKVO内部维持的Block将自动清理

使用 XKVOMonitor 宏

如果想使用类似 RAC 的 RACObserve 宏的方式,那么可以使用 XKVOMonitor 宏,同样具备属性语法检测功能:

    [XKVOMonitor(self, testCount) subscribe:^(XKVOValue * _Nonnull kvoValue) {
        NSLog(@"监听到 testCount 属性变化 %@ ==> %@", kvoValue.oldValue, kvoValue.changedNewValue);
    } initialNotify:NO];

信号发送与监听

  1. #import <XKVO/XKVO.h>
@property (nonatomic, strong) XSignal * loginSignal; // 登录信号
  1. 创建信号对象
    _loginSignal = [[XSignal alloc] init];
  1. 添加信号发送代码
    if (_loginSignal)
    {
        _loginSignal.emit(@"小白菜");
    }
  1. 添加信号监听代码
    [_loginSignal addBlock:^(NSString * x){
        NSLog(@"监听者1号 收到账号登录事件 nick:%@", x);
    } observer:self];
    
    XSignalMonitor * monitor =
    [_loginSignal addBlock:^(NSString * x){
        NSLog(@"监听者2号 收到账号登录事件 nick:%@", x); 
    } observer:self];
    [monitor close]; // 监听者2号不会打印,因为调用了 [monitor close]
    
    [_loginSignal addBlock:^(NSString * x){
        NSLog(@"监听者3号 收到账号登录事件 nick:%@", x);
    } observer:self];

运行程序,会看到如下日志输出:

   监听者1号 收到账号登录事件 nick:小白菜
   监听者3号 收到账号登录事件 nick:小白菜

两个监听者都收到了账号登录事件,因为2号监听者取消了监听,所以不再会收到信号。

注意事项

  • 如果想中途取消信号监听,那么在 subscribe 或 xkvo_addObserver 时记录返回值 XSignalMonitor,在适当的时机调用 -[XSignalMonitor close] 即可取消监听,参考 XKVODemo 示例代码。
  • 如果信号需要传递多个参数,那么 XSignal 构造的时候,可以将字典作为 ValueType,XSignal<NSDictionary *>,这样监听的时候,block回调的声明参数也会变成 NSDictionary * x,发送信号的时候,将多个参数组装成字典即可。
  • 对于KVO监听,只是对系统KVO的二次封装,因此跟系统KVO一样,调用方必须是调用其 setter 方法才有效,另外如果调用时传入了原来的值,也会收到监听。
  • 监听方必须传入监听者 Observer,目的是确保监听者销毁后,XKVO 持有的 Block 会自动释放,不产生内存泄漏。
  • 防重入:为了避免多次添加相同的Block,尽量不要在某个事件发生时再添加监听,推荐在一个对象初始化时就设置好监听,确保一个监听只设置一次。

关于设计

作为可复用模块库,或者是一个 SDK 的设计,不应该要求它的使用者也去使用某一个框架,虽然 XKVO 只是一个小小的工具,算不上一个框架,但还是不适合在 SDK 的对外接口中暴露这些细节,而应该再做一层封装,对外只提供原生的接口方式,降低学习成本(最少知识原则)。 现以做一个简单的音频播放器作为例子,讲解一下对外接口的设计。

如果在项目中,有场景需要播放音频,那么我们就很可能去封装一个音频播放器,提供音频播放能力,由业务方传入音频文件名或 URL,发起音频播放,播放过程会将播放进度回调给业务方,播放完毕也会有相应的事件通知。另外,事件通知的接收方可能不止一个,因为UI层需要根据播放进度更新UI显示,播放完毕后,肯能有另一个业务需要继续播放下一首曲目,播放进度也不只UI层需要监听,可能产品层面要求如果播放过程出现崩溃,下次启动要能从上次播放进度位置开始接着播放。这就要求封装的播放器能够提供一对多的事件通知,使用系统的 Delegate 接口显然就达不到要求了,使用广播通知的话,虽然也能实现功能,但耦合太松散,设计不够优雅,这个时候, XKVO 就能派上用场了。

#import "XSignal.h"

@interface AudioPlayer : NSObject

- (void)play:(NSString *)filePath;

@property (nonatomic, strong) XSignal * playStartSignal;
@property (nonatomic, strong) XSignal<NSNumber *> * playProgressSignal;
@property (nonatomic, strong) XSignal * playFinishSignal;

@end

按以上的设计,要求业务方调用如下代码来获取进度通知是可行的:

    [self.playProgressSignal subscribe:^(id  _Nullable x) {
        onProgress(x);
    } observer:self];

可以实现一对多的通知,但是,这样违背了最小知识原则,就是会要求使用者必须使用XSignal,如果使用者不了解这个,看到这样的接口可能就会放弃了,所以我们有必要对这个对外接口做个封装,去掉 #import "XSignal.h",不将 XKVO 暴露在对外接口中。

优化后的代码如下:

// AudioPlayer.h
@interface AudioPlayer : NSObject

- (void)play:(NSString *)filePath;

// 注册播放事件通知
- (void)onPlayStart:(void (^ _Nonnull)(void))onSartBlock;
- (void)onPlayProgress:(void (^ _Nonnull)(NSNumber * progress))onProgressBlock;
- (void)onPlayFinish:(void (^ _Nonnull)(void))onFinishBlock;

@end

对外接口头文件中不再包含额外的头文件。

// AudioPlayer.m

#import <XKVO/XKVO.h>

@interface AudioPlayer ()

@property (nonatomic, strong) XSignal * playStartSignal;
@property (nonatomic, strong) XSignal<NSNumber *> * playProgressSignal;
@property (nonatomic, strong) XSignal * playFinishSignal;

@end

@implementation AudioPlayer

- (instancetype)init
{
    self = [super init];
    if (self)
    {
        _playStartSignal = [[XSignal alloc] init];
        _playProgressSignal = [[XSignal alloc] init];
        _playFinishSignal = [[XSignal alloc] init];
    }
    
    return self;
}

- (void)play:(NSString *)filePath
{
    dispatch_async(dispatch_get_main_queue(), ^{
        self.playStartSignal.emit(nil);
        self.playProgressSignal.emit(@(0));
    });
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.playProgressSignal.emit(@(0.5));
    });

    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.playProgressSignal.emit(@(1.0));
        self.playFinishSignal.emit(nil);
    });
}


- (void)onPlayStart:(void (^)(void))onSartBlock
{
    [self.playStartSignal subscribe:^(id  _Nullable x) {
        onSartBlock();
    } observer:self];
}

- (void)onPlayProgress:(void (^ _Nonnull)(NSNumber * progress))onProgressBlock
{
    [self.playProgressSignal subscribe:^(id  _Nullable x) {
        onProgressBlock(x);
    } observer:self];
}

- (void)onPlayFinish:(void (^ _Nonnull)(void))onFinishBlock
{
    [self.playFinishSignal subscribe:^(id  _Nullable x) {
        onFinishBlock();
    } observer:self];
}
@end

对功能实现方来说,新增了事件注册封装转发代码,稍微麻烦了一些,但是为了使用者简单,无需学习成本,这样的代价是值得的。功能实现者可以麻烦一些,不能将麻烦丢给使用方,接口设计一定要让用户使用简单(一看就会,只需要少量说明,怎么用都不会出乱子)。用的爽了,才会推荐给更多人使用。

业务使用方代码如下:

@property (nonatomic, strong) AudioPlayer * audioPlayer;

    self.audioPlayer = [[AudioPlayer alloc] init];
    [self.audioPlayer play:@"file.mp3"];
    
    [self.audioPlayer onPlayStart:^{
        NSLog(@"音乐已经开始播放...");
    }];
    
    [self.audioPlayer onPlayFinish:^{
        NSLog(@"音乐播放结束.");
    }];
    
    [self.audioPlayer onPlayProgress:^(NSNumber * _Nonnull progress) {
        NSLog(@"音乐播放进度:%@",  progress);
    }];

项目源码

gitee XKVO 源码