[SwiftUI][Swift] MVVM と Swift Concurrency を組み合わせる(1: Model を actor に)

SwiftUI2021

     
MVVM と Concurrency を組み合わせたアーキテクチャを考えてみます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.3 beta4
  • Xcode 13.2
  • iOS 15.2

MVVM と async/sync

Swift5.5 から concurrency ということで並行性をサポートする機能が追加されています。
例えば、非同期に実行できる/されることを明示するための async/await があります。
MVVM に async を組み合わせたくなる理由を考えてみます。そして、Concurrency をうまく活用するために MVVM にどのように組み込んでいくと良いのかを考えてみます。

MVVM のうち、View は、MainThread から操作されることが前提だと思いますので、concurrency を活用するとしても、並行性からはあまり影響を受けません。
MVVM のうち concurrency に影響を大きく受けるのが、Model です。Source of truth として 複数スレッドからの並行した操作に対しても整合性を保つことができると嬉しいハズです。

このことに対して Swift は、Sendable や actor という型を導入してサポートしようとしています。

複数スレッドからの操作に対して、actor 等が登場する前は「気をつけてコードを書く」がその対策でしたが、actor 等が登場したので、それらの Swift の機能をうまく使うことで、整合性を担保しやすくなります。

actor

Swift5.5 で新しく登場した actor は、型としては、class と同じ reference 型です。

actor が導入された目的は、データ競合の抑止です。

actor には、データ競合を防ぐための仕組みが入っているため、以下のような特徴があります。

・プロパティ/メソッドへのアクセスは、async になる

どのように防がれるかについては、以下のドキュメントで、温度と最大温度を記録するクラスを例として説明されています。

ドキュメントは、こちら

actor を使ったモデル定義

例題として、カウントダウンを管理するモデルを定義してみます。

Countdown という名前にしました。

機能としては、以下です。
・外部からカウントダウンのための初期値を受け取る
・要求に応じてカウントダウンの数字を減らす (or 増やす)
・カウントダウンの数字を外部に提供する
・カウントダウンの数字は、必ず0以上が保たれる

最初に class として定義してみます。


//
//  Countdown.swift
//
//  Created by : Tomoaki Yagishita on 2022/03/01
//  © 2022  SmallDeskSoftware
//

import Foundation

class Countdown {
    var count: Int = 0
    
    init(_ count: Int) {
        precondition(count >= 0)
        self.count = count
    }
    
    func decrement() {
        if count == 0 { return } // (1)
        count -= 1               // (2)
    }
    func increment() {
        count += 1
    }
}

普通に動きそうですし、たいていのケースでは 問題なく動作しそうですが、このコードでは、Concurrency でいうところの data races(データ競合)が起こり得ます。

どのような時に起こるかというと 複数スレッドから「カウントダウンの数字を減らす処理」が呼ばれるケースです。
decrement のメソッドの中で、(1) では count を負にしないために値をチェック、その後 (2) で count を -1 しています。

(1) -> (2) と常に連続で処理されていれば問題ありません。ですが、別スレッドが偶然にも(?)同じタイミングで 同じ Countdown インスタンスの decrement を呼び出してしまうと 問題が発生します。

(1) の処理を行い、(2) の処理前に、別スレッドからの decrement が実行されてしまうと、別スレッドの処理結果として count が 0 に書き替わり、その後 自処理で count を -1 するので、結果として、count は -1を持ってしまいます。

このようなことを防ぐために、decrement での処理中は 別スレッドが count を操作できないようにブロックする排他処理が必要となります。排他処理のためには、いろいろな方法がありますが、OS が提供する semaphore もそのための仕組みの一つです。別の方法としては、特定のスレッドプールからのみの操作というルールにし、順序をつけて処理していくという方法もあります。

排他処理は 誤った実装をしてしまうと dead lock 等の別の問題が発生してしまうこともあるため、注意深く実装する必要があります。通常、マルチスレッド処理は、設計も実装も難しく、デバッグも難しいという状況です。

ここで役立つのが actor です。actor として定義されたクラスは、1つのプロパティ・メソッド等がアクセスされている間は、別のスレッドからアクセスされないことを Swift (のランタイム) が保証してくれます。

このケースでは、decrement が呼ばれている間は、別スレッドから decrement を呼び出すことはできません。

具体的には、Countdown を actor として定義することで decrement は、非同期(async) でしか呼び出せないという定義に変わります。そのため あるスレッドが処理のためにすでに何らかのメソッドやプロパティを参照している途中であれば、別スレッドからの呼び出し/参照は 待たされます。(つまり、呼び出し/参照 箇所は suspension point になるということです)

actor を定義する

actor を定義するのは簡単で、class であったところを actor にすれば OK です。


//
//  Countdown.swift
//
//  Created by : Tomoaki Yagishita on 2022/03/01
//  © 2022  SmallDeskSoftware
//

import Foundation

actor Countdown {
    var count: Int = 0
    
    init(_ count: Int) {
        precondition(count >= 0)
        self.count = count
    }
    
    func decrement() {
        if count == 0 { return }
        count -= 1
    }
    func increment() {
        count += 1
    }
}

使い方は、テストコードで見ていきます。

actor の UnitTest

以下は、Countdown をテストするためのコードです。


@testable import CountdownTimer
import XCTest

final class Tests: XCTestCase {

    func test_countdown_decrement() async throws {
        let sut = Countdown(5)

        let value1 = await sut.count
        XCTAssertEqual(value1, 5)
        
        await sut.decrement()
        let value2 = await sut.count
        XCTAssertEqual(value2, 4)
    }
}

Countdown のメソッドを呼ぶ時/プロパティにアクセスする時、いずれも await をつけてアクセスしていることがわかります。

通常の class 定義では、メソッドに async が付与されて定義されている時だけ必要な await ですが、actor のプロパティ・メソッドについては、await を付与してアクセスすることが必要となります。

await を付けていますので、この参照・呼び出し箇所は、suspension point になり得ます。つまり、このコードは実行途中で サスペンドされるかもしれません。ですので、テストメソッド自体も async が付与されたコードになっています。

UnitTest を書き慣れていると、2行で書いている箇所を1行に書き換えたくなるかもしれません。


        let value2 = await sut.count
        XCTAssertEqual(value2, 4)

        XCTAssertEqual(await sut.count, 4)  // -> エラーとなる

残念ながら、XCTAssertEqual 内部で、await が付与されたコードを処理することはできないので、上記のように記述することはできません。(コンパイルエラーになります。)
ですので、毎回 プロパティ値を変数に受けることが必要となっています。

actor を Model として使用するということ

actor を使うことで、常に整合性をキープできるモデルを作りやすくなります。

しかし、actor を使うと、プロパティには、async/await でしかアクセスできなくなりますので、ViewModel には影響が出ます。ViewModel だけでなく (ViewModel 経由で) View にも影響が出てきます。

次回以降は、actor を Model にすることの影響を確認していきます。

まとめ

actor を使うことで、Model の整合性を保持しやすくなることを確認しました。

actor を Model に使う時に気をつけること
  • actor を使って Model 定義すると、Concurrency からの整合性を保ちやすくなる
  • プロパティやメソッドは、async 定義になる
  • アクセスするときには、await でアクセスが必要となる

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。