$30 off During Our Annual Pro Sale. View Details »

シングルトンではじめる状態管理と依存注入 / A way to control state using singleton pattern

シングルトンではじめる状態管理と依存注入 / A way to control state using singleton pattern

ROPPONGI.swift 第7回 (try! Swift Tokyo 2019 プレイベント) での発表資料です。
https://roppongi-swift.connpass.com/event/122146/

Tomohiro Imaizumi

March 07, 2019
Tweet

More Decks by Tomohiro Imaizumi

Other Decks in Programming

Transcript

  1. シングルトンではじめる
    状態管理と依存注⼊
    ROPPONGI.swift #7 at Diverse.inc
    @imaizume

    View Slide

  2. • 今泉 智博 (@imaizume)
    • 株式会社Diverse / 株式会社Uzumaki
    • マッチングアプリPoiboy iOS版開発担当
    • Diverse Podcast にも出てます
    • 近況: 最近レーシックを受けました
    ⾃⼰紹介

    View Slide

  3. 1. 状態管理が⾟い話
    2. ブーストの状態管理をシングルトンにした話
    3. 性別を依存注⼊する話
    4. まとめ
    本⽇のINDEX

    View Slide

  4. • プロフィール
    • ユーザーランク
    • 課⾦情報
    • アイテム使⽤状態
    • キャンペーン参加状態
    • Betaユーザー etc
    アプリ開発で⾟いこと
    アプリ内の状態管理
    状態更新
    表⽰更新
    状態参照
    状態参照 状態更新
    状態の例

    View Slide

  5. Matching Dev Meetup #1 でも…
    Reduxを取り⼊れてpairs開発はどう変わったか (@satoshin21)
    > とにかく複雑な状態管理
    「タップル誕⽣」における開発の変化 /
    change_development (@corin8823)
    > 状態管理の多さによる
    複雑さを減少させたい
    男⼥出し分けと会員状態で画⾯切り分けが⼤変な話 (@kikuchy)
    > クライアント開発は状態整理との戦い

    View Slide

  6. Matching Dev Meetup #1 でも…
    Reduxを取り⼊れてpairs開発はどう変わったか (@satoshin21)
    > とにかく複雑な状態管理
    「タップル誕⽣」における開発の変化 /
    change_development (@corin8823)
    > 状態管理の多さによる
    複雑さを減少させたい
    男⼥出し分けと会員状態で画⾯切り分けが⼤変な話 (@kikuchy)
    > クライアント開発は状態整理との戦い
    どのマッチングアプリも
    状態管理に苦労していた

    View Slide

  7. 最近のトレンドRedux(Flux)❓
    Fundamentals of Swift & Redux (ReduxとSwiftの組み合わせ) (@fumiyasac)
    Reduxの原則
    1. Single source of truth (ソースは1つだけ)
    2. State is read-only (状態は読み取り専⽤)
    3. Mutations are written as pure functions
    (変更はすべて純粋関数で書かれる)

    View Slide

  8. 良さそうではあるが…
    すぐの導⼊は難しそう
    (開発リソース⾯)
    ViewControllerが複雑
    (複数責務/状態分散)
    ↓ViewControllerの中

    View Slide

  9. 良さそうではあるが…
    すぐの導⼊は難しそう
    (開発リソース/学習コスト)
    ViewControllerが複雑
    (複数責務/状態分散)
    ↓ViewControllerの中
    Redux原則1:
    “Single source of truth”
    状態集約だけでも実現したい…

    View Slide

  10. シングルトンパターンの利⽤
    そんな⽅にオススメの⽅法

    View Slide

  11. シングルトンパターンとは
    • アプリ内に存在するインスタンスが1つ
    • どのモジュールからも参照可能
    • Swiftではstatic letで宣⾔するだけ
    • アプリ内の状態を⼀箇所に集約・管理
    今回はPoiboyでの実装を参考に
    シングルトンによる状態管理の例を紹介
    class PurchaseManager() {
    /// γϯάϧτϯΠϯελϯε
    static let shared:
    PurchaseManager = .init()
    }
    課⾦状態を知りたい!
    画面A 画面B 画面C
    シングルトンオブジェクト
    唯⼀の状態変数

    View Slide

  12. 例1: ブースト状態の同期

    View Slide

  13. Poiboyのブースト機能について
    • ブーストは男性が使えるアイテム
    • 使⽤すると⼀定時間⼥性側への露出が増加
    • 使⽤中はボタンに残り時間を表⽰
    • ボタンタップでポップアップを表⽰

    View Slide

  14. ブースト関連の画⾯構成
    • ポップアップはブースト前/中/後の3種類
    • (余談)以前は1つのViewControllerで内部分岐してた
    ブースト前 ブースト中 ブースト後
    メイン画面
    今回話すところ
    今回話すところ

    View Slide

  15. QAからのバグ報告
    • カウント⽤Timerを画⾯ごとに作成 (=状態が分散)
    • ポップアップは若⼲初期化から表⽰が遅れる
    • その結果残り時間の表⽰に差が⽣じていた
    12:34
    メイン画⾯ ポップアップ
    12:35
    通信/初期化 present
    (初期値=12:34)

    View Slide

  16. 対応: ブースト状態を1箇所に集約
    • BoostTimeManager(シングルトンクラス)を定義
    • 残り時間のラベル更新にはオブザーバー同期を使⽤
    12:34
    メイン画⾯ ポップアップ
    12:35
    present
    (初期値=12:34)
    メイン画⾯ ポップアップ
    ブースト時間管理用クラス
    12:34
    present
    12:34
    更新 更新
    参照
    BEFORE AFTER
    残り時間B
    残り時間A
    ←唯一の残り時間

    View Slide

  17. コード(プロパティ)
    class BoostTimeManager {
    /// γϯάϧτϯΠϯελϯε
    static let shared: BoostTimeManager = .init()
    /// ϒʔετதͰ͋Ε͹trueΛฦ͢
    var isInBoostTime: Bool { return self.remainingSeconds > 0 }
    /// ϒʔετͷ࢒Γ࣌ؒ(ඵ)
    private(set) var remainingSeconds: Int = 0
    /// Χ΢ϯτμ΢ϯͷͨΊͷλΠϚʔΠϯελϯε
    private var timer: Timer?
    ⼀部のプロパティのみ公開し不意の変更・更新を極⼒防ぐ

    View Slide

  18. コード (Timerでのカウントダウン)
    /// ϒʔετͷΧ΢ϯτμ΢ϯΛ։࢝
    /// طʹ࣮ߦதͷ৔߹͸ݱࡏͷॲཧΛఀࢭ͠৽نʹΧ΢ϯτμ΢ϯΛ։࢝
    func startCountDown(duration: Int) {
    self.remainingSeconds = duration
    self.timer?.invalidate()
    guard self.isInBoostTime else {
    self.timer = nil
    return
    }
    let timer = Timer.scheduledTimer(
    timeInterval: 1.0,
    target: self,
    selector: #selector(self.countDown),
    userInfo: nil,
    repeats: true
    )
    timer.fire()
    self.timer = timer
    }

    View Slide

  19. コード (カウントダウン時の通知)
    /// ϒʔετͷΧ΢ϯτμ΢ϯΛ࣮ߦ͠Observer΁௨஌͢Δ
    /// ϒʔετ͕ऴ͍ྃͯ͠Δ৔߹͸λΠϚʔΛఀࢭ͠ऴྃ͢Δ
    @objc private func countDown() {
    guard self.isInBoostTime else {
    self.timer?.invalidate()
    self.timer = nil
    return
    }
    self.remainingSeconds -= 1
    let center = NotificationCenter.default
    center.post(
    name: .boostTimeNotification,
    object: self.remainingSeconds
    )
    }
    }
    通知時にobjectに追加情報を付加可能

    View Slide

  20. コード (Notification.Name)
    extension Notification.Name {
    /// ϒʔετͷ࢒Γ࣌ؒ؂ࢹ
    public static let boostTimeNotification =
    Notification.Name("boostTime")
    }
    ʮSwift 3 Ҏ߱ͷ NotificationCenter ͷਖ਼͍͠࢖͍ํʯ (@mono0926) ΑΓ

    View Slide

  21. コード (ポップアップ画⾯1)
    class BoostLiveViewController: UIViewController {
    override func viewDidLoad() {
    // ϒʔετ࢒Γ࣌ؒͷߋ৽ͱ௨஌
    let center = NotificationCenter.default
    let selector = #selector(
    self.updateBoostTime(notification:)
    )
    center.addObserver(
    self, selector: selector,
    name: .boostTimeNotification,
    object: nil
    )
    }
    deinit {
    let center = NotificationCenter.default
    center.removeObserver(
    self, name: .boostTimeNotification, object: nil
    )
    }
    iOS9.0以降は明⽰的な登録解除が不要に

    View Slide

  22. コード (ポップアップ画⾯2)
    /// ࢒Γ࣌ؒͷߋ৽௨஌Λड͚औͬͨΒϒʔετؔ࿈ͷViewΛߋ৽
    @objc fileprivate func updateBoostTime(
    notification: Notification
    ) {
    if BoostTimeManager.sharedInstance.isInBoostTime {
    /// ϒʔετதͳΒςΩετΛߋ৽

    self.timerLabel.text = DateTimeUtil.secondToMmss(
    BoostTimeManager.sharedInstance.remainingSeconds
    )
    } else {
    /// ϒʔετ͕ऴྃͨ͠ΒϙοϓΞοϓΛด͡Δ
    self.dismiss(
    animated: true,
    completion: self.dismissCallback
    )
    }
    }
    }
    MVPであればPresenterから更新してあげる

    View Slide

  23. 結果
    BEFORE AFTER

    View Slide

  24. ここまでのまとめ
    • シングルトンクラスを通じて状態を参照/更新
    • 画⾯ごとの状態(ModelやTimer)を集約可能
    • リアルタイムなViewの反映にはオブザーバー同期を活⽤

    View Slide

  25. Ϣχοτςετ͸ૄ݁߹͕લఏͱͳ͍ͬͯͯɺ·ͨʮຊ෺ͷʯ࣮૷ίʔυͷ୅Θ
    ΓʹɺదٓϞοΫ ςετ༻ͷِऀ
    ࣮૷ʹࠩ͠ସ͑ΒΕΔ͜ͱ΋ॏཁ͔ͩΒͰ͢ɻ
    γϯάϧτϯύλʔϯ͸ͦͷ๦͛ʹͳΔͷͰ͢ɻ
    γϯάϧτϯύλʔϯ͸ӬଓԽ͞Εͨঢ়ଶΛ҉໧ͷ͏ͪʹ൐͏ɻ
    ʜதུʜ
    ࣮ߦલʹ͸ඞͣɺϓϩάϥϜΛط஌ͷঢ়ଶʹઃఆͰ͖Δ͸ͣͳͷͰ͢ɻෆมͰͳ
    ͍ঢ়ଶΛ൐͏γϯάϧτϯΛಋೖͯ͠͠·͏ͱɺͦΕ͕೉͘͠ͳΓ·͢ɻ
    Ճ͑ͯɺάϩʔόϧʹΞΫηεՄೳͰɺ͔ͭӬଓԽ͞Εͨঢ়ଶ͕͋Δͱɺίʔυ
    ͔Βঢ়ଶͷਪ࿦͕ࠔ೉ʹͳΓ·͢ɻ
    シングルトンの⽋点: Unitテストのしづらさ
    • どこからでも参照可能→暗黙的⼊⼒に
    • 全体で状態を更新する→暗黙的な状態永続化
    [プログラマが知るべき97のこと No.70: 「シングルトンパターンの誘惑に負けない」より

    View Slide

  26. スタブを利⽤し明⽰的な⼊⼒に変換
    • 暗黙的⼊⼒をスタブ化
    • 必要な状態を依存注⼊可能にする
    • 他のテストへの副作⽤を発⽣させない
    ʮ୯ମςετͷϋδϝʯ (@yokoyas000) ΑΓ

    View Slide

  27. 例2: 性別情報の取得

    View Slide

  28. 性別情報を差し替え可能にする
    • Poiboyではユーザー情報をシングルトンから取得
    • そこで性別情報を差し替え可能にする設計に変更
    • 通常時はシングルトンから状態を取得
    • テストやデバッグではスタブから取得
    <>
    • isMale: Bool
    • isFemale: Bool
    • genderCode: Int?
    [GenderStore]
    (KeychainManagerの値を返す)
    [GenderStoreStub]
    (指定した値をそのまま返す)
    本実装 Unitテスト
    デバッグ用実装
    スタブ 本物
    protocol

    View Slide

  29. コード(Protocol定義)
    /**
    ੑผ৘ใΛࢀর͢ΔͨΊͷΦϒδΣΫτ΁࣮૷͞ΕΔ΂͖ΠϯλʔϑΣΠε
    */
    protocol GenderStoreContract {
    /// genderCode = 0ͰtrueΛฦ͢
    var isMale: Bool { get }
    /// genderCode = 1ͰtrueΛฦ͢
    var isFemale: Bool { get }
    /// ݱঢ়nilͷ৔߹͕ߟྀ͞ΕͨઃܭͳͷͰ͜͜Ͱ΋ఆٛ
    var genderCode: Int? { get }
    }
    ”Protocol Oriented Programming”

    View Slide

  30. コード(実装向けクラス)
    /**
    ࣮ࡍʹΞϓϦʹอଘ͞Ε͍ͯΔੑผΛࢀরͯ͠ฦͨ͢ΊͷΫϥε
    */
    class GenderStore: GenderStoreContract {
    var isFemale: Bool { return KeychainManager.shared.isFemale }
    var isMale: Bool { return KeychainManager.shared.isMale }
    var genderCode: Int? {
    return KeychainManager.shared.getGenderCode()
    }
    } シングルトンインスタンスを参照

    View Slide

  31. コード(スタブクラス)
    /**
    ೚ҙͷੑผ/GenderCodeʹࠩ͠ସ͑ΒΕͨੑผΛฦ͢ελϒͷΫϥε
    */
    class GenderStoreStub: GenderStoreContract {
    let isFemale: Bool
    let isMale: Bool
    let genderCode: Int?
    init(isFemale: Bool, isNilGenderCode: Bool) {
    self.isFemale = isFemale
    self.isMale = !isFemale
    if isNilGenderCode {
    self.genderCode = nil
    } else {
    self.genderCode = isFemale
    ? GenderType.female.rawValue
    : GenderType.male.rawValue
    }
    }
    }
    差し替えた値を参照

    View Slide

  32. コード(GETパラメータでの性別差し替え)
    /// ϦΫΤετURLʹ෇༩͢ΔGETύϥϝʔλΛந৅Խͨ͠ྻڍܕ
    /// ύϥϝʔλΛੜ੒͢ΔͨΊʹඞཁͳ৘ใΛassociated valueʹ౉͢
    enum GetParameterType {
    case gender(GenderStoreContract)
    var keyValueString: String? {
    switch self {
    case .gender(let store):
    guard let code = store.genderCode else { return nil }
    return "gender_code=\(code)"
    }
    }
    }
    /// ࣮ࡍʹબ୒͍ͯ͠ΔAPITargetΛฦ͢
    class APITargetStore: APITargetStoreContract {
    let target: APITarget
    = .init(KeychainManager.shared.getApiTarget())
    }
    APITargetも依存注⼊可能な設計に

    View Slide

  33. コード(URLパラメータで性別差し替え)
    enum WebViewLink: String {
    /// ϔϧϓ: உঁͰίϯςϯπ͕ҟͳΔ͜ͱʹ஫ҙ
    case help = “/api/web/help“
    func toURLString(
    params: params: [GetParameterType] = [],
    api: api: APITargetStoreContract = APITargetStore()
    ) -> String {
    let baseUrl: String
    = api.target.baseWebUrlString + self.rawValue
    let parameterStrings: [String]
    = params.flatMap { $0.keyValueString }
    let joinedString: String

    = parameterStrings.joined(separator: "&")
    return “\(baseUrl)“ +
    “\(parameterStrings.isEmpty ? "" : "?\(joinedString)")"
    }
    }
    デフォルトでは実際の値を返すオブジェクトを使⽤

    View Slide

  34. コード(テストでの性別差し替えの例)
    /// URL͕ਖ਼͘͠ੜ੒͞ΕΔ͜ͱΛอূ͢Δςετ
    XCTAssertEqual(
    WebViewLink.help.toURLString(
    params: [.gender(GenderStoreStub(
    isFemale: true, isNilGenderCode: false))],
    api: APITargetStoreStub(target: .dev01)
    ),
    “https://dev01.hoge.com/api/web/help?gender_code=2”
    )
    }
    XCTAssertEqual(
    WebViewLink.help.toURLString(
    gender: .gender(GenderStoreStub(
    isFemale: false, isNilGenderCode: true)),
    api: APITargetStoreStub(target: .dev02)
    ),
    “https://dev02.hoge.com/api/web/help”
    )
    }
    テストではスタブにより差し替えた値を使⽤
    テスト以外にデバッグ時の強制表⽰などでも使えます

    View Slide

  35. シングルトンのメリットデメリット
    • 複数シングルトン間の連携処理 (Reduxにするタイミング?)
    • 排他制御(Lock)は⾃前実装が必要 (Reduxはどうなってる?)
    • 初期化コストの⾼い処理を⼀⻫に⾏うと動作が遅くなる?
    • 状態が1箇所に集約される
    • 責務をある程度適切に切り出すことが可能
    • ラッパークラスにより依存注⼊も可能
    メリット
    )デメリット

    View Slide

  36. まとめ
    • シングルトンパターンで簡易的に状態管理を実現
    • ViewControllerから責務や状態を分離する第⼀歩に
    • リアルタイム更新にはオブザーバー同期を活⽤
    • テストやデバッグのための依存注⼊を実現するために
    • Protocol Orientedな実装にする
    • 差し替えのためのスタブを定義する
    • 正しい責務分離を⼼がけ設計の改善につなげましょう❗

    View Slide

  37. 参考リンク
    • Reduxを取り⼊れてpairs開発はどう変わったか (@satoshin21)
    https://speakerdeck.com/satoshin21/reduxwoqu-riru-retekai-fa-hapairskai-fa-hadoubian-watutaka
    • 「タップル誕⽣」における開発の変化 / change_development (@corin8823)
    https://speakerdeck.com/corin8823/change-development
    • 男⼥出し分けと会員状態で画⾯切り分けが⼤変な話 (@kikuchy)
    https://docs.google.com/presentation/d/1jglMmosRj4n4T0QEJ18iR6kgxLQjQWDCHv2NcF1QQR8
    • リアルタイム情報同期 (@yuyakaido)
    https://speakerdeck.com/yuyakaido/matching-dev-meetup-1
    • Fundamentals of Swift & Redux (ReduxとSwiftの組み合わせ) (@fumiyasak)
    https://www.slideshare.net/fumiyasakai37/fundamentals-of-swift-redux-reduxswift
    • Swift 3 以降の NotificationCenter の正しい使い⽅ (@mono0926)
    https://qiita.com/mono0926/items/754c5d2dbe431542c75e
    • シングルトンパターンの誘惑に負けない | プログラマが知るべき97のこと
    https://プログラマが知るべき97のこと.com/エッセイ/シングルトンパターンの誘惑に負けない/
    • 単体テストのハジメ (@yokoyas000)
    https://speakerdeck.com/yokoyas000/dan-ti-tesutofalsehazime

    View Slide