Pairs JP – iOSでプロフィール項目のViewModelをプロトコルで上手く書いている話

この記事はeureka Native Advent Calendar 2017 – Qiitaの16日目の記事です。こんにちは。Pairs JP事業部でスクラムマスター & iOS / Webエンジニアをしているです。本記事は、Pairs JPのプロフィール項目の実装でプロトコルをうまく使っている方法についてご紹介します。

目次

  • Pairsのプロフィール項目
  • MasterItemの定義
  • 実際の使い方
  • 紹介したサンプルコードのまとめ

Pairsのプロフィール項目

Pairsのプロフィール項目はアプリ内の核となる機能です。自身のプロフィール、お相手を探す検索条件、お相手からのいいね!のフィルター条件など、アプリの多くの機能に関わっています。プロフィール項目としては、

  • 年齢
  • 居住地
  • 性格
  • 結婚に対する意思

など20項目ほど存在しています。

Pairs JPでは、プロフィール項目をMasterItemという呼び方をしているので、本記事では以下、MasterItemという名称を使用します。MasterItemはModelにあたりますが、テンプレートのようなものでインスタンスが作られることはありません。例えば、自身のプロフィールの更新時には、Meというオブジェクト(CoreDataのNSManagedObjectのサブクラス)が更新されます。MasterItemは年齢や居住地など扱い方が異なるプロフィール項目を区別できるようにしているだけです。

MasterItemの定義

MaterItemTypeというプロトコルを定義しています。

protocol MasterItemType {    
  static var key: String { get }
}

NamespaceのためにenumでMasterItemを作っています。さらに全てのMasterItemの要素は、structで定義するとインスタンスを作れてしまうため、enumで定義しています。

enum MasterItem {
  enum Age: MasterItemType {
    static let key = "age"
    // 他の制約などを書ける
    static let range: CountableClosedRange = (18 ... 65)  
  }

  enum Residence: MasterItemType {   
    static let key = "residence"
  }
  ...
}

実際の使い方

今回は女性ユーザーのお相手からのいいねを絞り込む画面を例にします。Pairs JPでは新しく作った画面などは、MVVMアーキテクチャで書かれています。MVVMになっているのは、各検索フィールド(年齢、居住地など)です。各検索フィールドのModelはMasterItemが担っています。

とはいえ画面全体で見ると完全なMVVMアーキテクチャではなく、ViewがModelを多少操作しています。画面下の「この条件を検索」ボタンを押すことで、それぞれのフィールドのViewModelから値を受け取って、APIリクエストを実行しています。

MasterItemTypeを継承したプロトコル

MasterItemTypeをお相手を絞り込む画面用に拡張したプロトコルを定義します。

protocol FromPartnerFilterableMasterItem: MasterItemType {

  associatedtype FromPartnerFilterValueType
  associatedtype FromPartnerFilterAccessoryViewType: UIView

  static func titleFor(fromPartnerFilter filterCondition: FromPartnerFilterMasterViewModel<Self>) -> String

  static func requiredPaymentStatus(for me: PRSMe) -> UserSubscriptionService.Category?

  static func accessoryView(_ cachedView: FromPartnerFilterAccessoryViewType?, FromPartnerFilterMasterViewModel filterCondition: FromPartnerFilterMasterViewModel<Self>, animated: Bool) -> (view: FromPartnerFilterAccessoryViewType, showsDetailIndicator: Bool)

  static func didTapFilter(viewController: UIViewController, FromPartnerFilterMasterViewModel filterCondition: FromPartnerFilterMasterViewModel<Self>, me: PRSMe) -> Observable<Void>

  static func condition(from filter: FromPartnerFilter?) -> FromPartnerFilterMasterViewModel<Self>

  static func append(value: FromPartnerFilterValueType, toFromPartnerFilterRequest json: inout JSON)    
}

さらに、以下のようにAccessoryViewTypeとValueTypeで制限をしたextensionを書くこともできます。

extension FromPartnerFilterableMasterItem where FromPartnerFilterAccessoryViewType == UISwitch, FromPartnerFilterValueType == Bool {
  // UISwitchに特化したaccessoryViewを書いたりする
  static func accessoryView(_ cachedView: FromPartnerFilterAccessoryViewType?, FromPartnerFilterMasterViewModel filterCondition: FromPartnerFilterMasterViewModel<Self>, animated: Bool) -> (view: FromPartnerFilterAccessoryViewType, showsDetailIndicator: Bool)
}

MasterItemをプロトコルに準拠させる
MasterItem.Ageを以下のように拡張します。年齢の下限と上限を表せるようなValueTypeにしています。

extension MasterItem.Age: FromPartnerFilterableMasterItem {

  typealias FromPartnerFilterValueType = (minimum: Int?, maximum: Int?)
  typealias FromPartnerFilterAccessoryViewType = UILabel

  ...
}

ViewModel

ViewModel側はジェネリクスになっており、先ほどのFromPartnerFilterableMasterItemを指定しています。

final class FromPartnerFilterMasterViewModel

View ↔ ViewModel
各フィールドのViewModelをまとめて扱いたいので配列にしましょう。

let viewModels: [FromPartnerFilterMasterViewModel] = [
  FromPartnerFilterMasterViewModel<MasterItem.Age>(),
  FromPartnerFilterMasterViewModel<MasterItem.Residence>()
]

しかし、これはコンパイルエラーになります。

cannot convert value of type 'FromPartnerFilterMasterViewModel' to expected element type 'FromPartnerFilterMasterViewModel'
let viewModels: [FromPartnerFilterMasterViewModel] = [
  FromPartnerFilterMasterViewModel<MasterItem.Age>(),
  FromPartnerFilterMasterViewModel<MasterItem.Residence>()
]

これもコンパイルエラーになります。

using 'FromPartnerFilterableMasterItem' as a concrete type conforming to protocol 'FromPartnerFilterableMasterItem' is not supported

解決方法

ViewModelが準拠すべきプロトコルを用意します。

protocol AnyFromPartnerFilterMasterViewModel {
}

final class FromPartnerFilterMasterViewModel: AnyFromPartnerFilterMasterViewModel

すると、View側では、

let viewModels: [AnyFromPartnerFilterMasterViewModel] = [
  FromPartnerFilterMasterViewModel<MasterItem.Age>(),
  FromPartnerFilterMasterViewModel<MasterItem.Residence>()
]

として、コンパイルエラーを避けることができます。View側はこのviewModelsをどのMasterItemかを意識することなく、扱うことが可能になります。

紹介したサンプルコードのまとめ

同じようなパターンでプロフィール項目を各画面で実装しています。わかりやすくまとめると、

protocol ___MasterItem: MasterItemType {}
protocol Any___ViewModel {}

// ジェネリクスで定義したViewModel
class ___ViewModel: Any___ViewModel {
}

// View
let viewModels: [Any___ViewModel] = [
  ___ViewModel<MasterItem.Age>(),
  ___ViewModel<MasterItem.Residence>()
]

という構造になっています。

さいごに

今回はプロフィール項目のViewModelを例にして、ジェネリクスをプロトコルを使って上手く扱う方法を紹介しました。今回のようなユースケースは他にもあると思うので、最後の「紹介したサンプルコードのまとめ」の部分だけでも読んでもらえると嬉しいです。

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

2年間オンプレを運用してきた人が、2ヶ月AWSの運用をした所感

【最強】iOSアプリのデバイス毎の確認をiOSシミュレータを複数起動して最速で行う【最高】