ROPPONGI.swift 第7回 (try! Swift Tokyo 2019 プレイベント) での発表資料です。 https://roppongi-swift.connpass.com/event/122146/
シングルトンではじめる状態管理と依存注⼊ROPPONGI.swift #7 at Diverse.inc@imaizume
View Slide
• 今泉 智博 (@imaizume)• 株式会社Diverse / 株式会社Uzumaki• マッチングアプリPoiboy iOS版開発担当• Diverse Podcast にも出てます • 近況: 最近レーシックを受けました⾃⼰紹介
1. 状態管理が⾟い話2. ブーストの状態管理をシングルトンにした話3. 性別を依存注⼊する話4. まとめ本⽇のINDEX
• プロフィール• ユーザーランク• 課⾦情報• アイテム使⽤状態• キャンペーン参加状態• Betaユーザー etcアプリ開発で⾟いことアプリ内の状態管理状態更新表⽰更新状態参照状態参照 状態更新状態の例
Matching Dev Meetup #1 でも…Reduxを取り⼊れてpairs開発はどう変わったか (@satoshin21)> とにかく複雑な状態管理「タップル誕⽣」における開発の変化 /change_development (@corin8823)> 状態管理の多さによる複雑さを減少させたい男⼥出し分けと会員状態で画⾯切り分けが⼤変な話 (@kikuchy)> クライアント開発は状態整理との戦い
Matching Dev Meetup #1 でも…Reduxを取り⼊れてpairs開発はどう変わったか (@satoshin21)> とにかく複雑な状態管理「タップル誕⽣」における開発の変化 /change_development (@corin8823)> 状態管理の多さによる複雑さを減少させたい男⼥出し分けと会員状態で画⾯切り分けが⼤変な話 (@kikuchy)> クライアント開発は状態整理との戦いどのマッチングアプリも状態管理に苦労していた
最近のトレンド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(変更はすべて純粋関数で書かれる)
良さそうではあるが…すぐの導⼊は難しそう(開発リソース⾯)ViewControllerが複雑(複数責務/状態分散)↓ViewControllerの中
良さそうではあるが…すぐの導⼊は難しそう(開発リソース/学習コスト)ViewControllerが複雑(複数責務/状態分散)↓ViewControllerの中Redux原則1:“Single source of truth”状態集約だけでも実現したい…
シングルトンパターンの利⽤そんな⽅にオススメの⽅法
シングルトンパターンとは• アプリ内に存在するインスタンスが1つ• どのモジュールからも参照可能• Swiftではstatic letで宣⾔するだけ• アプリ内の状態を⼀箇所に集約・管理今回はPoiboyでの実装を参考にシングルトンによる状態管理の例を紹介class PurchaseManager() {/// γϯάϧτϯΠϯελϯεstatic let shared:PurchaseManager = .init()}課⾦状態を知りたい!画面A 画面B 画面Cシングルトンオブジェクト唯⼀の状態変数
例1: ブースト状態の同期
Poiboyのブースト機能について• ブーストは男性が使えるアイテム• 使⽤すると⼀定時間⼥性側への露出が増加• 使⽤中はボタンに残り時間を表⽰• ボタンタップでポップアップを表⽰
ブースト関連の画⾯構成• ポップアップはブースト前/中/後の3種類• (余談)以前は1つのViewControllerで内部分岐してたブースト前 ブースト中 ブースト後メイン画面今回話すところ今回話すところ
QAからのバグ報告• カウント⽤Timerを画⾯ごとに作成 (=状態が分散)• ポップアップは若⼲初期化から表⽰が遅れる• その結果残り時間の表⽰に差が⽣じていた12:34メイン画⾯ ポップアップ12:35通信/初期化 present(初期値=12:34)
対応: ブースト状態を1箇所に集約• BoostTimeManager(シングルトンクラス)を定義• 残り時間のラベル更新にはオブザーバー同期を使⽤12:34メイン画⾯ ポップアップ12:35present(初期値=12:34)メイン画⾯ ポップアップブースト時間管理用クラス12:34present12:34更新 更新参照BEFORE AFTER残り時間B残り時間A←唯一の残り時間
コード(プロパティ)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?⼀部のプロパティのみ公開し不意の変更・更新を極⼒防ぐ
コード (Timerでのカウントダウン)/// ϒʔετͷΧϯτμϯΛ։࢝/// طʹ࣮ߦதͷ߹ݱࡏͷॲཧΛఀࢭ͠৽نʹΧϯτμϯΛ։࢝func startCountDown(duration: Int) {self.remainingSeconds = durationself.timer?.invalidate()guard self.isInBoostTime else {self.timer = nilreturn}let timer = Timer.scheduledTimer(timeInterval: 1.0,target: self,selector: #selector(self.countDown),userInfo: nil,repeats: true)timer.fire()self.timer = timer}
コード (カウントダウン時の通知)/// ϒʔετͷΧϯτμϯΛ࣮ߦ͠Observer௨͢Δ/// ϒʔετ͕ऴ͍ྃͯ͠Δ߹λΠϚʔΛఀࢭ͠ऴྃ͢Δ@objc private func countDown() {guard self.isInBoostTime else {self.timer?.invalidate()self.timer = nilreturn}self.remainingSeconds -= 1let center = NotificationCenter.defaultcenter.post(name: .boostTimeNotification,object: self.remainingSeconds)}}通知時にobjectに追加情報を付加可能
コード (Notification.Name)extension Notification.Name {/// ϒʔετͷΓ࣌ؒࢹpublic static let boostTimeNotification =Notification.Name("boostTime")}ʮSwift 3 Ҏ߱ͷ NotificationCenter ͷਖ਼͍͍͠ํʯ (@mono0926) ΑΓ
コード (ポップアップ画⾯1)class BoostLiveViewController: UIViewController {override func viewDidLoad() {// ϒʔετΓ࣌ؒͷߋ৽ͱ௨let center = NotificationCenter.defaultlet selector = #selector(self.updateBoostTime(notification:))center.addObserver(self, selector: selector,name: .boostTimeNotification,object: nil)}deinit {let center = NotificationCenter.defaultcenter.removeObserver(self, name: .boostTimeNotification, object: nil)}iOS9.0以降は明⽰的な登録解除が不要に
コード (ポップアップ画⾯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から更新してあげる
結果BEFORE AFTER
ここまでのまとめ• シングルトンクラスを通じて状態を参照/更新• 画⾯ごとの状態(ModelやTimer)を集約可能• リアルタイムなViewの反映にはオブザーバー同期を活⽤
Ϣχοτςετૄ݁߹͕લఏͱͳ͍ͬͯͯɺ·ͨʮຊͷʯ࣮ίʔυͷΘΓʹɺదٓϞοΫ ςετ༻ͷِऀ࣮ʹࠩ͠ସ͑ΒΕΔ͜ͱॏཁ͔ͩΒͰ͢ɻγϯάϧτϯύλʔϯͦͷ͛ʹͳΔͷͰ͢ɻγϯάϧτϯύλʔϯӬଓԽ͞Εͨঢ়ଶΛ҉ͷ͏ͪʹ͏ɻʜதུʜ࣮ߦલʹඞͣɺϓϩάϥϜΛطͷঢ়ଶʹઃఆͰ͖ΔͣͳͷͰ͢ɻෆมͰͳ͍ঢ়ଶΛ͏γϯάϧτϯΛಋೖͯ͠͠·͏ͱɺͦΕ͕͘͠ͳΓ·͢ɻՃ͑ͯɺάϩʔόϧʹΞΫηεՄೳͰɺ͔ͭӬଓԽ͞Εͨঢ়ଶ͕͋Δͱɺίʔυ͔Βঢ়ଶͷਪ͕ࠔʹͳΓ·͢ɻシングルトンの⽋点: Unitテストのしづらさ• どこからでも参照可能→暗黙的⼊⼒に• 全体で状態を更新する→暗黙的な状態永続化[プログラマが知るべき97のこと No.70: 「シングルトンパターンの誘惑に負けない」より
スタブを利⽤し明⽰的な⼊⼒に変換• 暗黙的⼊⼒をスタブ化• 必要な状態を依存注⼊可能にする• 他のテストへの副作⽤を発⽣させないʮ୯ମςετͷϋδϝʯ (@yokoyas000) ΑΓ
例2: 性別情報の取得
性別情報を差し替え可能にする• Poiboyではユーザー情報をシングルトンから取得• そこで性別情報を差し替え可能にする設計に変更• 通常時はシングルトンから状態を取得• テストやデバッグではスタブから取得<>• isMale: Bool• isFemale: Bool• genderCode: Int?[GenderStore](KeychainManagerの値を返す)[GenderStoreStub](指定した値をそのまま返す)本実装 Unitテストデバッグ用実装スタブ 本物protocol
コード(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”
コード(実装向けクラス)/**࣮ࡍʹΞϓϦʹอଘ͞Ε͍ͯΔੑผΛࢀরͯ͠ฦͨ͢ΊͷΫϥε*/class GenderStore: GenderStoreContract {var isFemale: Bool { return KeychainManager.shared.isFemale }var isMale: Bool { return KeychainManager.shared.isMale }var genderCode: Int? {return KeychainManager.shared.getGenderCode()}} シングルトンインスタンスを参照
コード(スタブクラス)/**ҙͷੑผ/GenderCodeʹࠩ͠ସ͑ΒΕͨੑผΛฦ͢ελϒͷΫϥε*/class GenderStoreStub: GenderStoreContract {let isFemale: Boollet isMale: Boollet genderCode: Int?init(isFemale: Bool, isNilGenderCode: Bool) {self.isFemale = isFemaleself.isMale = !isFemaleif isNilGenderCode {self.genderCode = nil} else {self.genderCode = isFemale? GenderType.female.rawValue: GenderType.male.rawValue}}}差し替えた値を参照
コード(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も依存注⼊可能な設計に
コード(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.rawValuelet parameterStrings: [String]= params.flatMap { $0.keyValueString }let joinedString: String = parameterStrings.joined(separator: "&")return “\(baseUrl)“ +“\(parameterStrings.isEmpty ? "" : "?\(joinedString)")"}}デフォルトでは実際の値を返すオブジェクトを使⽤
コード(テストでの性別差し替えの例)/// 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”)}テストではスタブにより差し替えた値を使⽤テスト以外にデバッグ時の強制表⽰などでも使えます
シングルトンのメリットデメリット• 複数シングルトン間の連携処理 (Reduxにするタイミング?)• 排他制御(Lock)は⾃前実装が必要 (Reduxはどうなってる?)• 初期化コストの⾼い処理を⼀⻫に⾏うと動作が遅くなる?• 状態が1箇所に集約される• 責務をある程度適切に切り出すことが可能• ラッパークラスにより依存注⼊も可能メリット)デメリット
まとめ• シングルトンパターンで簡易的に状態管理を実現• ViewControllerから責務や状態を分離する第⼀歩に• リアルタイム更新にはオブザーバー同期を活⽤• テストやデバッグのための依存注⼊を実現するために• Protocol Orientedな実装にする• 差し替えのためのスタブを定義する• 正しい責務分離を⼼がけ設計の改善につなげましょう❗
参考リンク• 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