シングルトンではじめる状態管理と依存注入 / 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/

1a74617b91d2757b839b9cf3614648ce?s=128

Tomohiro Imaizumi

March 07, 2019
Tweet

Transcript

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

  2. • 今泉 智博 (@imaizume) • 株式会社Diverse / 株式会社Uzumaki • マッチングアプリPoiboy

    iOS版開発担当 • Diverse Podcast にも出てます • 近況: 最近レーシックを受けました ⾃⼰紹介
  3. 1. 状態管理が⾟い話 2. ブーストの状態管理をシングルトンにした話 3. 性別を依存注⼊する話 4. まとめ 本⽇のINDEX

  4. • プロフィール • ユーザーランク • 課⾦情報 • アイテム使⽤状態 • キャンペーン参加状態

    • Betaユーザー etc アプリ開発で⾟いこと アプリ内の状態管理 状態更新 表⽰更新 状態参照 状態参照 状態更新 状態の例
  5. Matching Dev Meetup #1 でも… Reduxを取り⼊れてpairs開発はどう変わったか (@satoshin21) > とにかく複雑な状態管理 「タップル誕⽣」における開発の変化

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

    / change_development (@corin8823) > 状態管理の多さによる 複雑さを減少させたい 男⼥出し分けと会員状態で画⾯切り分けが⼤変な話 (@kikuchy) > クライアント開発は状態整理との戦い どのマッチングアプリも 状態管理に苦労していた
  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 (変更はすべて純粋関数で書かれる)
  8. 良さそうではあるが… すぐの導⼊は難しそう (開発リソース⾯) ViewControllerが複雑 (複数責務/状態分散) ↓ViewControllerの中

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

    truth” 状態集約だけでも実現したい…
  10. シングルトンパターンの利⽤ そんな⽅にオススメの⽅法

  11. シングルトンパターンとは • アプリ内に存在するインスタンスが1つ • どのモジュールからも参照可能 • Swiftではstatic letで宣⾔するだけ • アプリ内の状態を⼀箇所に集約・管理

    今回はPoiboyでの実装を参考に シングルトンによる状態管理の例を紹介 class PurchaseManager() { /// γϯάϧτϯΠϯελϯε static let shared: PurchaseManager = .init() } 課⾦状態を知りたい! 画面A 画面B 画面C シングルトンオブジェクト 唯⼀の状態変数
  12. 例1: ブースト状態の同期

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

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

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

    ポップアップ 12:35 通信/初期化 present (初期値=12:34)
  16. 対応: ブースト状態を1箇所に集約 • BoostTimeManager(シングルトンクラス)を定義 • 残り時間のラベル更新にはオブザーバー同期を使⽤ 12:34 メイン画⾯ ポップアップ 12:35

    present (初期値=12:34) メイン画⾯ ポップアップ ブースト時間管理用クラス 12:34 present 12:34 更新 更新 参照 BEFORE AFTER 残り時間B 残り時間A ←唯一の残り時間
  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? ⼀部のプロパティのみ公開し不意の変更・更新を極⼒防ぐ
  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 }
  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に追加情報を付加可能
  20. コード (Notification.Name) extension Notification.Name { /// ϒʔετͷ࢒Γ࣌ؒ؂ࢹ public static let

    boostTimeNotification = Notification.Name("boostTime") } ʮSwift 3 Ҏ߱ͷ NotificationCenter ͷਖ਼͍͠࢖͍ํʯ (@mono0926) ΑΓ
  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以降は明⽰的な登録解除が不要に
  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から更新してあげる
  23. 結果 BEFORE AFTER

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

  25. Ϣχοτςετ͸ૄ݁߹͕લఏͱͳ͍ͬͯͯɺ·ͨʮຊ෺ͷʯ࣮૷ίʔυͷ୅Θ ΓʹɺదٓϞοΫ ςετ༻ͷِऀ ࣮૷ʹࠩ͠ସ͑ΒΕΔ͜ͱ΋ॏཁ͔ͩΒͰ͢ɻ γϯάϧτϯύλʔϯ͸ͦͷ๦͛ʹͳΔͷͰ͢ɻ γϯάϧτϯύλʔϯ͸ӬଓԽ͞Εͨঢ়ଶΛ҉໧ͷ͏ͪʹ൐͏ɻ ʜதུʜ ࣮ߦલʹ͸ඞͣɺϓϩάϥϜΛط஌ͷঢ়ଶʹઃఆͰ͖Δ͸ͣͳͷͰ͢ɻෆมͰͳ ͍ঢ়ଶΛ൐͏γϯάϧτϯΛಋೖͯ͠͠·͏ͱɺͦΕ͕೉͘͠ͳΓ·͢ɻ Ճ͑ͯɺάϩʔόϧʹΞΫηεՄೳͰɺ͔ͭӬଓԽ͞Εͨঢ়ଶ͕͋Δͱɺίʔυ

    ͔Βঢ়ଶͷਪ࿦͕ࠔ೉ʹͳΓ·͢ɻ シングルトンの⽋点: Unitテストのしづらさ • どこからでも参照可能→暗黙的⼊⼒に • 全体で状態を更新する→暗黙的な状態永続化 [プログラマが知るべき97のこと No.70: 「シングルトンパターンの誘惑に負けない」より
  26. スタブを利⽤し明⽰的な⼊⼒に変換 • 暗黙的⼊⼒をスタブ化 • 必要な状態を依存注⼊可能にする • 他のテストへの副作⽤を発⽣させない ʮ୯ମςετͷϋδϝʯ (@yokoyas000) ΑΓ

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

  28. 性別情報を差し替え可能にする • Poiboyではユーザー情報をシングルトンから取得 • そこで性別情報を差し替え可能にする設計に変更 • 通常時はシングルトンから状態を取得 • テストやデバッグではスタブから取得 <<GenderStoreContract>>

    • isMale: Bool • isFemale: Bool • genderCode: Int? [GenderStore] (KeychainManagerの値を返す) [GenderStoreStub] (指定した値をそのまま返す) 本実装 Unitテスト デバッグ用実装 スタブ 本物 protocol
  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”
  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() } } シングルトンインスタンスを参照
  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 } } } 差し替えた値を参照
  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も依存注⼊可能な設計に
  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)")" } } デフォルトでは実際の値を返すオブジェクトを使⽤
  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” ) } テストではスタブにより差し替えた値を使⽤ テスト以外にデバッグ時の強制表⽰などでも使えます
  35. シングルトンのメリットデメリット • 複数シングルトン間の連携処理 (Reduxにするタイミング?) • 排他制御(Lock)は⾃前実装が必要 (Reduxはどうなってる?) • 初期化コストの⾼い処理を⼀⻫に⾏うと動作が遅くなる? •

    状態が1箇所に集約される • 責務をある程度適切に切り出すことが可能 • ラッパークラスにより依存注⼊も可能 メリット )デメリット
  36. まとめ • シングルトンパターンで簡易的に状態管理を実現 • ViewControllerから責務や状態を分離する第⼀歩に • リアルタイム更新にはオブザーバー同期を活⽤ • テストやデバッグのための依存注⼊を実現するために •

    Protocol Orientedな実装にする • 差し替えのためのスタブを定義する • 正しい責務分離を⼼がけ設計の改善につなげましょう❗
  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