[Swift] enum のすすめ Vol.3 (アプリの状態復元と Codable)

     

TAGS:

enum のすすめ(Codable と アプリの状態復元)

有限状態のいずれかを表すという特徴は、アプリケーションでの状態を表すのに最適です。アプリケーションの状態変数として使う enum について 考察してみます。

環境&対象

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

  • macOS Monterey 12.4
  • Xcode 13.3.1
  • iOS 15.4

アプリの状態管理

ここまでの記事で、enum は、有限個の状態のいずれかであることを表現する と説明しました。

有限個の状態で表現するものとして、アプリの状態があります。
# 前回は タイマーアプリで説明しましたが、引き続きタイマーアプリを使って説明していきます。

タイマーとしては、以下の状態をとり得るものとして定義していました。
・タイマー動作中
・タイマー停止中
associatedValue をうまく使い、タイマー動作中には、タイマー停止予定時間の情報も併せて持たせていました。
以下のような 定義です。


enum AppState {
     case TimerRunning(timerExpire: Date)
     case TimerStopped
}

上記の enum はアプリの状態を過不足なく表現しています。

この enum をうまく使うことで、アプリの状態復元も容易にできるようになります。

「アプリの状態復元」とは、一旦アプリを終了させた後で再起動したときに、終了直前の状態に戻すことを指しています。

例えば、タイマーアプリを実行中に ふと 別アプリを確認したくなることはないでしょうか?
このような時に動作しているタイマーをキャンセルしたいことは 稀ですので、再度タイマーアプリを起動した時には、以前使用していたタイマーが残っていて欲しいです。

このようなケースでは、タイマーアプリ終了時点での状態を保存しておき、再起動された時に、保存された状態から アプリの状態を復元することで 終了直前の状態に戻ることができます。

今回のタイマーアプリで考えると、アプリの状態は enum に詰まっていますので、起動時に この enum を復元することで 終了直前の状態に復元することができます。

状態を保存してくれないアプリの挙動

状態を保存してくれないアプリは、以下のような挙動をとることになります。

# 通常バックグラウンドに移行させるだけではアプリは残ったままですので、無理矢理アプリを終了させて再起動させています。

Codable

enum を保存/復元する方法は様々な方法が考えられますが、今回は Codable に準拠させることで実現していきます。

実は、enum を Codable に準拠させるのは簡単です。


enum AppState: Codable { // - Codable を追加
    case TimerRunning(timerExpire: Date)
    case TimerStopped
}

enum に Codable と宣言を追加すると あとは Swift が処理してくれます。

RawRepresentable で定義していても、その型が Swift の基本型であれば OK です。

Swift5.5 以降では、associatedValue を持つ enum でも処理してくれます。
今回は associatedValue として Date 型を使用していますが、特に encode/decode を自分で記述することなく Codable に準拠させられます。

AppStorage/SceneStorage

アプリケーションやビューの状態を保存してくれるプロパティラッパーとして、@AppStorage や @SceneStorage があります。

大変便利なプロパティラッパーなのですが、対応している型は、Swift の基本型のみです・・・・

ということで、enum には、そのままでは使えません。

自前での 保存/復元コード

@AppStorage/@SceneStorage が使えないので、自分で 状態を保存するコード と 状態を復元するコードを書かなくてはいけません。

とはいっても Codable に準拠しているデータを 保存/復元することはそんなに難しくないです。

肝心なのは 保存/復元する タイミングです。

状態の保存

状態の保存は、ViewModel が破棄される時に行うことにしました。

つまり、 deinit で処理します。


class ViewModel: ObservableObject {
    ... omit ...
    let userDefaultsKey = "AppState"
    deinit {
        if let appStateData = try? JSONEncoder().encode(appState) {
            UserDefaults.standard.set(appStateData, forKey: userDefaultsKey)
        }
    }
    ... omit ...
}

状態の復元

同様に、状態の復元は、ViewModel が生成される時に行うことにしました。

つまり、init で処理します。


class ViewModel: ObservableObject {
    ... omit ...
    let userDefaultsKey = "AppState"
    
    init() {
        if let appStateData = UserDefaults.standard.data(forKey: userDefaultsKey),
           let appStateFromUserDefaults = try? JSONDecoder().decode(AppState.self, from: appStateData) {
            self.appState = appStateFromUserDefaults
        } else {
            appState = .initialState
        }
    }
}

init で状態を復元する時には、UserDefaults に保存されていないケースを考慮する必要があります。
例えば、アプリの初回起動時には、UserDefaults には保存されていないハズなので、UserDefaults からの値によらず 初期値を設定することが必要となります。

状態を保存してくれるアプリの挙動

上記の init/deinit を実装すると次のような動作になります。

enum で管理しない場合を考察

アプリの状態を enum で管理しない時には、Swift の基本型を組み合わせて管理することになります。
今回のタイマーの場合は、以下のようになります。


var timerStatus: Int // 1: running, 0: Not running, else: undefined
var expireDate: Date? // !=nil: expire date , ==nil: no expire date

enum と比較すると 以下のような良い点悪い点があります。
良い点
・@AppStorage/@SceneStorage を使って 簡単に保存/復元できる
悪い点
・1つの変数で管理していないので、複数の変数間で不整合が発生することがあり得ます。
(例:timerStatus == running で、expireDate == nil の時 どうするか?)

通常、アプリの状態は複雑化していく一方なので、アプリ状態の整合性を容易に保てるように enum で管理するのがおすすめです。

enum を使用すれば、running でかつ expireDate==nil という状態は起こり得ません。

まとめ

enum を使って、アプリの状態を管理し、さらにアプリの状態を保存/復元する方法を説明しました。

アプリケーションの状態変数として使う enum
  • アプリの状態を associatedValue を含めた enum で管理すると 不整合を防ぎやすい
  • enum で管理すると 保存/復元に @AppStorage/@SceneStorage は使えない
  • Swift5.5 以降では、associatedValue を持つ enum も Codable に簡単に準拠させられる

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

コード全体

以下は 動画に使用したアプリのコード(ContentView.swift) です。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/06/14
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        VStack {
            Text("Timer").font(.title).padding(.bottom, 30)
            if let expireDate = viewModel.expireDateInString() {
                Text("Expire at \(expireDate)")
            } else {
                Text("Timer is not running")
            }
            Button(action: {
                viewModel.appState = .TimerRunning(timerExpire: Date().advanced(by: 60*3))
            }, label: {
                Text("3 min Timer")
            })
            .buttonStyle(.bordered).padding(.top, 30)
        }
            .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

enum AppState: Codable {
    case TimerRunning(timerExpire: Date)
    case TimerStopped
    
    static let initialState = AppState.TimerStopped
}

class ViewModel: ObservableObject {
    @Published var appState: AppState = AppState.initialState
    let userDefaultsKey = "AppState"
    
    init() {
        if let appStateData = UserDefaults.standard.data(forKey: userDefaultsKey),
           let appStateFromUserDefaults = try? JSONDecoder().decode(AppState.self, from: appStateData) {
            self.appState = appStateFromUserDefaults
        } else {
            appState = .initialState
        }
    }

    deinit {
        if let appStateData = try? JSONEncoder().encode(appState) {
            UserDefaults.standard.set(appStateData, forKey: userDefaultsKey)
        }
    }
    
    var dateFormatter: DateFormatter = {
        let df = DateFormatter()
        df.dateStyle = .none
        df.timeStyle = .medium
        return df
    }()
    
    func expireDateInString() -> String? {
        if case .TimerRunning(let timerExpire) = self.appState {
            return dateFormatter.string(from: timerExpire)
        }
        return nil
    }
}

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

Swift ポケットリファレンス

Swift を学んでも、プログラミング言語の文法を全て記憶しておくことは無理なので、ちょっとした文法の確認をするために、リファレンス本を手元に置いておくと便利です。

注意
Swift4 までしか対応していないので、相違点を理解して参照する必要があります。

そろそろ Swift5 に対応した版が欲しいですね・・・

コメントを残す

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