RxSwift-Todo II - 如何通过Subject传递数据

基于上个Todo的例子,在这段视频里,我们完成添加和编辑Todo任务的功能。在开始之前,先了解下基于上段视频完成的例子,我们做了哪些主要修改:

Todo
  • 修改了之前添加按钮的代码,让它打开一个创建Todo的View;
  • 给table cell的accessory添加了segue,让它打开一个编辑当前Todo的View;
  • 新建了一个TodoDetailViewController,处理添加和编辑Todo的逻辑;
  • TodoListViewController中,根据segue的目标修改了新打开View的标题;

大家可以在这里下载项目的起始模板。

新建Todo

接下来,我们就动手实现添加一个新的Todo。这个事情唯一的要点,就是如何把在TodoDetailViewController中创建的Todo内容,传递给TodoListViewController。在使用RxSwift之前,Cocoa的套路是这样的:

Todo
  1. 定义一个protocol,并让这个protocol类型的对象成为TodoDetailViewController的delegate;
  2. TodoListViewController实现这个protocol中的方法,并设置成TodoDetailViewController的delegate对象;
  3. TodoDetailViewController通过delegate方法发送数据;

于是,当我们在Controller中发送数据的时候,方法一直是“不对称”的,可以通过给属性赋值把数据“发出去”,但是却要通过protocol“传回来”。

借助RxSwift,我们可以更方便和统一地在Controllers之间发送数据。简单来说,让发送数据的一方包含一个Observable对象,让接收方直接订阅就好了。

Todo

创建PublishSubject

按照这个思路,我们先在TodoDetailViewController中,添加下面的代码:

class TodoDetailViewController: UITableViewController {
    fileprivate let todoSubject = PublishSubject<TodoItem>()
    var todo: Observable<TodoItem> {
        return todoSubject.asObservable()
    }
    // ...
}

在继续之前,思考两个问题:

  1. 为什么这里我们使用了一个Subject对象呢?
  2. 为什么这个Subject是一个PublishSubject呢?

对于问题一,是因为在TodoDetailViewController内部,我们需要一个Observer,它要订阅到UITextFieldUISwitch的值;但同时,我们也需要它是一个Observable,可以让TodoLisViewController订阅到之后更新Todo列表的显示。

而对于问题二,相信等我们完成Todo编辑之后,你自然就会明白了,我们暂且先不管它。

这里,我们还使用了一个小技巧,为了避免todoSubject意外从TodoDetailViewController外部接受onNext事件,我们把它定义成了fileprivate属性。对外,只提供了一个仅供订阅的Observable属性todo

实现onNext

接下来,我们要在用户创建Todo的时候,给todoSubject发送onNext事件。首先,给TodoDetailViewController添加一个保存Todo内容的属性:

class TodoDetailViewController: UITableViewController {
    var todoItem: TodoItem!
    // ...
}

其次,在viewWillAppear中初始化它:

class TodoDetailViewController: UITableViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        todoName.becomeFirstResponder()

        todoItem = TodoItem()
    }

    // ...
}

最后,在Done按钮的事件处理方法里,通知todoSubject

class TodoDetailViewController: UITableViewController {
    @IBAction func done() {
        todoItem.name = todoName.text!
        todoItem.isFinished = isFinished.isOn

        todoSubject.onNext(todoItem)
        dismiss(animated: true, completion: nil)
    }
}

至此,TodoDetailViewController这一侧的装修就完工了。我们可以到TodoListViewController去订阅了。

在另一个Controller中订阅

要在另外一个Controller中订阅todo,最核心的问题,就是如何得到TodoDetailViewController对象。然而,这对我们来说,并不是一个问题,通过Segue进行场景转换的时候,我们已经通过topViewController得到了。因此,在TodoListViewController中,添加下面的代码:

class TodoListViewController: UIViewController {
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        let naviController =
            segue.destination as! UINavigationController
        var todoDetailController =
            naviController.topViewController as! TodoDetailViewController

        if segue.identifier == "AddTodo" {
            todoDetailController.title = "Add Todo"

            todoDetailController.todo.subscribe(
                onNext: {
                    [weak self] newTodo in
                    self?.todoItems.value.append(newTodo)
                },
                onDisposed: {
                    print("Finish adding a new todo.")
                }
            ).addDisposableTo(bag)
        }
    }
}

其中,新增的代码,就是订阅todo的部分,我们从事件中订阅到要添加的内容,然后塞进todoItems,由于它也是响应式的,UITableView就能自动更新了。

资源被正常回收了么?

此时,尽管已经可以正常添加Todo了,但是如果你足够细心就可以发现,控制台并没有打印Finsih adding a new todo.的提示。也就是说,在dismissTodoDetailViewController之后,todoSubject并没有释放,我们应该在某些地方导致了资源泄漏。

为了进一步确认这个问题,在Podfile中添加下面的内容:

post_install do |installer|
  installer.pods_project.targets.each do |target|
    if target.name == 'RxSwift'
      target.build_configurations.each do |config|
        if config.name == 'Debug'
          config.build_settings['OTHER_SWIFT_FLAGS'] ||= ['-D', 'TRACE_RESOURCES']
        end
      end
    end
  end
end

简单来说,就是找到项目中的RxSwift target,在它的Debug配置中,添加-D TRACE_RESOURCES编译参数,并在Termianl中重新执行pod install更新下RxSwift。然后,在TodoDetailViewControllerviewWillAppear方法中,添加下面的代码:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    todoName.becomeFirstResponder()

    todoItem = TodoItem()

    print("Resource tracing: \(RxSwift.Resources.total)")
}

这样,我们就能在控制台看到当前RxSwift分配的资源计数。重新build并执行整个项目,然后多添加几个Todo,就会在控制台看到resource一直在增加:

Todo

为什么会这样呢?其实,看下订阅的代码就明白了:

todoDetailController.todo.subscribe(
    onNext: {
        [weak self] newTodo in
        self?.todoItems.value.append(newTodo)
    },
    onDisposed: {
        print("Finish adding a new todo.")
    }
).addDisposableTo(bag)

我们把todo.subscribe返回的订阅对象放在了TodoListViewController.bag里,但只要App不退出,作为initial view controller的TodoListViewController是不会被释放的,因此,它的bag里装的订阅对象只会越来越多。这显然不是我们想要的,怎么办呢?

一个“头疼医头”的办法,就是把todo.subscribe返回的订阅对象放在TodoDetailViewControllerbag里。这样,当controller被dismiss的时候,bag里的订阅就会自动被取消,todoSubject占用的资源也就被回收了。为了验证这个想法,我们在TodoDetailViewController中添加下面的代码:

class TodoDetailViewController: UITableViewController {
    // ...
    var bag = DisposeBag()
}

然后,修改TodoListViewController中的订阅代码:

todoDetailController.todo.subscribe(
    onNext: {
        [weak self] newTodo in
        self?.todoItems.value.append(newTodo)
    },
    onDisposed: {
        print("Finish adding a new todo.")
    }
).addDisposableTo(todoDetailController.bag)

重新编译执行,现在,多次打开添加Todo的界面,就会发现资源可以正常回收了:

Todo

但事情至此还没结束,可能你会觉得这样写代码感觉怪怪的,甚至有些危险。因为我们要依赖一个Controller(TodoDetailViewController)中的某个属性(bag)才能得以工作正常。而常规的开发经验通常告诉我们,如此密切的耦合关系通常是各种问题滋生的温床。这至多,只能算一个“非主流”的办法。

那么,更“主流”的办法是什么呢?

希望你还记得,对于一个Observable来说,除了所有订阅者都取消订阅会导致其被回收之外,Observable自然结束(onCompleted)或发生错误结束(onError)也会自动让所有订阅者取消订阅,并导致Observable占用的资源被回收。

因此,当TodoDetailViewController dismiss之后,实际上我们也不会再使用它添加新的Todo了,这时,我们应该给todoSubject发送onCompeleted事件,明确告知RxSwift,这个事件序列结束了:

class TodoDetailViewController: UITableViewController {
    // ...
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        todoSubject.onCompleted()
    }
}

于是,我们之前的订阅代码就可以进一步改成这样:

_ = todoDetailController.todo.subscribe(
    onNext: {
        [weak self] newTodo in
        self?.todoItems.value.append(newTodo)
    },
    onDisposed: {
        print("Finish adding a new todo.")
    }
)

至此,所有添加Todo的工作,就结束了。接下来,我们实现编辑的部分。

编辑Todo

编辑Todo,和新建Todo绝大部分工作都是一样的,只是相比新建,有两个关键问题要想清楚:

  • 如何把要编辑的内容传递给TodoDetailViewController
  • 编辑后的内容如何传回来更新UI;

第一个问题,我们可以在segue中通过Identifier来确定如果是编辑操作,就读取当前table view中被选中的cell,然后根据cell的IndexPath,读取到Todo的内容,并传递给TodoDetailViewController

有了这个思路之后,首先,我们来处理prepare(for:sender:)方法:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    let naviController = segue.destination as! UINavigationController
    var todoDetailController =
        naviController.topViewController as! TodoDetailViewController

    if segue.identifier == "AddTodo" {
        // ...
    }
    else if segue.identifier == "EditTodo" {
        // 1\. The edit segue
        todoDetailController.title = "Edit todo"

        // 2\. Get the selected cell index
        if let indexPath = tableView.indexPath(
            for: sender as! UITableViewCell) {

            // 3\. Pass the selected todo
            todoDetailController.todoItem =
                todoItems.value[indexPath.row]
        }
    }
}

这样,我们就把用户选择编辑的Todo,传递给了TodoDetailViewController.todoItem

其次,在TodoDetailViewController中,当todoItem不为nil时,我们要用它的内容初始化UI:

class TodoDetailViewController: UITableViewController {
    // ...

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        todoName.becomeFirstResponder()

        if let todoItem = todoItem {
            self.todoName.text = todoItem.name
            self.isFinished.isOn = todoItem.isFinished
        }
        else {
            todoItem = TodoItem()
        }

        print("Resource tracing: \(RxSwift.Resources.total)")
    }
}

第三,无论是新建还是编辑Todo,在最终提交操作的done方法里,我们都是给todoSubject发送一条onNext事件。接下来,只要回到TodoListViewController,在处理EditTodo的情况里,订阅这个事件更新UI就好了:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    // ...

    if segue.identifier == "AddTodo" {
        // ...
    }
    else if segue.identifier == "EditTodo" {
        todoDetailController.title = "Edit todo"

        if let indexPath =
            tableView.indexPath(for: sender as! UITableViewCell) {

            todoDetailController.todoItem =
                todoItems.value[indexPath.row]

            _ = todoDetailController.todo.subscribe(
                onNext: { [weak self] todo in
                    self?.todoItems.value[indexPath.row] = todo
                },
                onDisposed: {
                    print("Finish editing a todo.")
                }
            )
        }
    }
}

其中,最关键的,就是订阅到编辑过的Todo后,直接把它赋值给了当前正在编辑的Todo,由于todoItems是响应式的,因此整个UITableView就被自动更新了。

现在,可以回过头思考之前遗留的一个问题了,为什么在TodoDetailViewController中我们使用了PublishSubject,而不是其他的Subject呢?

这是因为其他的Subject会向事件的订阅者发送一个当前的默认值,当我们在segue中订阅事件的时候就会订阅到这个默认值。如果此时我们在新建Todo,那么就会同时创建出来两个Todo,一个是默认值,一个是用户自己添加的。这种行为,显然不是我们期望的。

What's next?

以上,就是这一节的内容,其中,最重要的内容有两点:

  • 如何通过Subject在Controllers之间传递数据;
  • 在Controllers之间传递数据的时候,如何正确的释放Subject资源;

在下一节,我们将对保存Todo列表的功能做一些修改,通过一个实际的例子,了解自定义Observable的应用场景。

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

推荐阅读更多精彩内容

  • 昨天有梦到,参加一个活动,我有些紧张,然后你给我发消息说,我陪你一起唱,我环顾四周,发现你躲在我背后磁墙的角...
    劉佳佩阅读 141评论 0 0
  • 我这些日子并不好过,吃不饱,喝不足,半个月竟然瘦了五斤。我不是想跟你们谈减肥疗效,也不是想告诉你们我有多自虐,而是...
    狗哥说阅读 1,164评论 0 3
  • 我这个人其实挺糟糕的,喜欢拖延,没什么承担力。 以前认为到了大学就可以随心所欲,到了大学才发现哪有那么多的时光可以...
    mao汤阅读 195评论 0 1
  • 删除 ~$ 开头的残留文件 指令:ls -a | grep '~$*' | xargs rm
    谢小帅阅读 2,319评论 0 0