RxSwift-Todo I - 通过一个真实的App体会Rx的基本概念

经过了前面几节的内容铺垫后,现在,是时候在一个真的App里感受下这些Rx的概念了。在日常的开发中,如何通过RxSwift绑定UI和Model?如何在不同的Controller之间共享数据?通过对这些内容的实践,你就会更真实的感受到之前提到的那些基本概念的含义。

当然,作为开始,我们的目标还不是一个MVVM架构的App,那是最终的目标。在这一节,我们先从一个常规开发的App开始,用Rx的思想改造一些常规功能的实现,以此,加深对Observable,Subscribe,Subject,Dispose这些概念的理解。

ToDo App

首先,大家可以在Github上下载RxToDoDemo源代码,进入ToDoDemoStarter目录,这是项目的起始源代码。先执行pod install安装RxSwift,完成后,打开ToDoDemo.xcworkspace

RxToDo

继续之前,我们先简单了解下这个项目:

首先,Model目录中,是App使用的数据,它是一个遵从NSCoding的类,方便我们序列化成plist保存和加载。其中,name表示ToDo的标题,isFinished表示是否完成;

class TodoItem: NSObject, NSCoding {
    var name: String = ""
    var isFinished: Bool = false

    // ...
}

其次,Assets目录中,是App的UI。在Main.storyboard中,我们希望点击App右上角的+添加新的todo,点击todo内容所在行可以用一个蓝色的对勾切换完成状态,下面的绿色按钮清空整个todo列表;蓝色按钮保存当前所有的todo内容和完成状态;

第三,Controllers目录中是目前App唯一的view controller。它包含了App的Model、@IBOutlet以及@IBAction。在最开始的这个版本里,为了简单起见,我们让所有添加的todo内容和状态都是相同的。

class TodoListViewController: UIViewController {
    var todoItems: [TodoItem] = []
    @IBOutlet weak var tableView: UITableView!

    required init?(coder aDecoder: NSCoder) {
        // ...
    }

    @IBAction func addTodoItem(_ sender: Any) {
        // ...
    }

    @IBAction func saveTodoList(_ sender: Any) {
        // ...
    }

    @IBAction func clearTodoList(_ sender: Any) {
        // ...
    }
}

最后,为了实现TodoListViewController中的功能,我们把一些具体的功能代码放在了Helper目录,其中TodoListTableView.swift中存放的是table view的data source以及delegate,TodoListViewConfigure.swift中存放的,则是保存和加载todo model相关的代码。

对Todo的响应式改造

Variable

对这个App的第一个改造,是让TodoListViewController中的model变成响应式的,为此,我们把之前的todoItems变成一个Variable,并添加一个用于回收取消订阅的DisposeBag对象:

// In TodoListViewController.swift

class TodoListViewController: UIViewController {
    let todoItems = Variable<[TodoItem]>([])
    let bag = DisposeBag()

    // ...
}

这样一来,所有之前直接访问todoItems数据的部分,都要改成访问todoItems.value。首先,是显示Todo列表的UITableView,打开TodoListTableView.swift,修改对应的data source和delegate方法。唯一需要注意的是,在左滑删除的代码里,我们只是删除了todoItems的数据,而没有执行删除cell UI的代码。稍后就会看到,在todoItems变成Variable之后,所有UI相关的代码,将会在对其的订阅中统一处理。

// UITableView delegate
extension TodoListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView,
                   didSelectRowAt indexPath: IndexPath) {
        if let cell = tableView.cellForRow(at: indexPath) {
            let todo = todoItems.value[indexPath.row]

            todo.toggleFinished()
            configureStatus(for: cell, with: todo)
        }

        tableView.deselectRow(at: indexPath, animated: true)
    }

    func tableView(_ tableView: UITableView,
                   commit editingStyle: UITableViewCellEditingStyle,
                   forRowAt indexPath: IndexPath) {
        todoItems.value.remove(at: indexPath.row)
    }
}

// UITableView data source
extension TodoListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView,
                   numberOfRowsInSection section: Int) -> Int {
        return self.todoItems.value.count
    }

    func tableView(_ tableView: UITableView,
                   cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
            withIdentifier: "TodoItem", for: indexPath)
        let todo = todoItems.value[indexPath.row]

        configureLabel(for: cell, with: todo)
        configureStatus(for: cell, with: todo)

        return cell
    }
}

其次,修改通过storyboard初始化的init?方法,此时,我们已经不需要在这里初始化todoItems了:

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    loadTodoItems()
}

第三,是序列化todoItems的时候,要改成访问todoItems.value属性。在TodoListViewConfigure.swift里,把saveTodoItemsloadTodoItems修改成下面这样:

func saveTodoItems() {
    let data = NSMutableData()
    let archiver = NSKeyedArchiver(forWritingWith: data)

    archiver.encode(todoItems.value, forKey: "TodoItems")
    archiver.finishEncoding()

    data.write(to: dataFilePath(), atomically: true)
}

func loadTodoItems() {
    let path = dataFilePath()

    if let data = try? Data(contentsOf: path) {
        let unarchiver = NSKeyedUnarchiver(forReadingWith: data)
        todoItems.value =
            unarchiver.decodeObject(forKey: "TodoItems") as! [TodoItem]

        unarchiver.finishDecoding()
    }
}

第四,把TodoListViewController.swift中,保存和清除Todo列表的代码改成这样:

class TodoListViewController: ViewController {
    @IBAction func addTodoItem(_ sender: Any) {
        let todoItem = TodoItem(name: "Todo Demo", isFinished: false)
        todoItems.value.append(todoItem)
    }

    @IBAction func clearTodoList(_ sender: Any) {
        todoItems.value.removeAll()
    }
}

可以看到,此时,这两部分代码也只是在处理todoItems自身,而没有UI相关的代码了。都修改完成之后,按Cmd + B构建一次,确认没有错误。现在,我们来着手处理当todoItems的值更新时,对应UI的修改。

由于todoItems是一个Subject,作为一个observer,我们修改它的值,就相当于它自己订阅到了事件。而要响应值的修改,我们就把它当作一个observable直接订阅就好了。在viewDidLoad里,添加下面的代码:

todoItems.asObservable().subscribe(
    onNext: { [weak self] todos in
    self?.updateUI(todos: todos)
}).addDisposableTo(bag)

很简单,当发现todoItems的值发生变化的时候,调用TodoListViewController中的updateUI方法更新界面,稍后,我们就来实现这个方法。现在,先来看onNext closure中捕获的self,为什么要用weak呢?

RxToDo

如上图所示,subscribe方法返回的Disposable对象被bag管理,因此bag持有一个strong reference;此时,如果Disposable对象的onNextclousre中持有指回self的strong reference,TodoListViewController对象和Disposable对象之间就会形成引用循环了。因此,这里要使用weak self

理解了这个问题之后,我们来实现updateUI方法:

func updateUI(todos: [TodoItem]) {
    self.tableView.reloadData()
}

很简单对不对?我们只要让tableView对象重新加载数据就好了,尽管这不是一个高效的方法,也还有很多交互细节可以改进,但至少你可以感觉到,通过Subject,我们把根据todoItems的值更新UI的代码,都放到了一起。

绑定更多和UI相关的操作

看到这里,你可能会觉得,这一点点小改进没什么,至少不足以激起Rx对你的兴趣。接下来,我们再对UI进行一点约束,例如:

  • 顶部的标题应该显示当前todo的个数;
  • 清空列表后应该禁用绿色按钮;
  • 限制最多只能存在4个未完成的todo,否则就禁用添加按钮;

传统的方式怎么办呢?你可能会想到针对todoItems利用KVO的机制来解决问题,但毕竟我们在使用Swift,一来,KVO只能处理有限类型的属性;二来,我们似乎一下子又回到了披着Swift外衣的OC世界;最后,KVO的使用在Swift中也真的非常不方便,单就那一长串#selector就会让代码看上去并不那么Swift。

现在,有了RxSwift,todoItems变成了一个Subject,为了实现上面的功能,我们只要在updateUI中添加几行代码就搞定了:

func updateUI(todos: [TodoItem]) {
    clearTodoBtn.isEnabled = !todos.isEmpty
    addTodo.isEnabled =
        todos.filter { !$0.isFinished }.count < 4
    title = todos.isEmpty ? "Todo" : "\(todos.count) ToDos"

    self.tableView.reloadData()
}

怎么样?是不是看着就很Swift。执行一下就会发现,前两个功能都好用,限制未完成todo个数的功能并不好用。这是因为,我们订阅的todoItems并不会响应数组中成员的属性被修改的事件,因此,编辑已有todo的完成状态并不会给todoItems发送通知。这里,一个简单的办法就是,在TodoListTableView.swift中,把处理cell自动反选的代码改成这样:

func tableView(_ tableView: UITableView,
               didSelectRowAt indexPath: IndexPath) {
    if let cell = tableView.cellForRow(at: indexPath) {
        let todo = todoItems.value[indexPath.row]
        todo.toggleFinished()

        // Trigger event
        todoItems.value[indexPath.row] = todo

        configureStatus(for: cell, with: todo)
    }

    tableView.deselectRow(at: indexPath, animated: true)
}

通过给对应位置的todoItems赋值,我们就可以变相触发事件,进而订阅到todoItems的值了。

What's next?

在这个简单的例子里,我们开始把一个用传统方式编写的App,进行一点改进,初步体会了如何通过RxSwift把更新Model和更新UI的代码进行分离。但此时,添加新Todo的功能还没有实现,在下一节,我们就来看如何通过Subject简化在不同的Controller之间传递数据,并实现新建和编辑Todo的功能。

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

推荐阅读更多精彩内容