DecodableのDecodeを簡潔に書きたい
Swift4でDecodableつかってますか〜?
公式にサポートしてもらえると本当にありがたいですよね。
しかしながら汎用的なパースをしようとすると若干、コードが冗長になります
公式ドキュメントによると、カスタムなデコードの戦略を図る場合は下記のようにイニシャライザとデコードを実装する必要があります
extension Coordinate: Decodable { init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) latitude = try values.decode(Double.self, forKey: .latitude) longitude = try values.decode(Double.self, forKey: .longitude) let additionalInfo = try values.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo) elevation = try additionalInfo.decode(Double.self, forKey: .elevation) } }
冗長ですね。値がnilや予期しない値だった場合のハンドリングが考慮されていないのでバギーです
これらを簡潔に書く軽いExtensionを作ってみたので、例をもとに説明してみたいと思います。
目指した要件は2点です
- パースが失敗したときのデフォルト値を設定する
- String以外で表現できるプリミティブな型がStringで渡ってくる場合のフォールバック
1. パースが失敗したときのデフォルト値を設定する
犬が受けたワクチンをリストするAPIのJSONのレスポンスを例にしてみます。
Dog.json
[ { "name": "Spring", "vaccinations": [ { "name": "Rabies" }, { "name": "Corona" } ] } ]
構造体
Dog.swift
// 予防接種の種類 struct Vaccination: Decodable { let name: String } struct Dog: Decodable { let name: String let vaccinations: [Vaccination] }
例えば、予防接種を受けた数を表現するとき、受けたことがなかったら空の配列を作りたいとします。
しかし、空の配列は返さず、nullもしくはそもそもそのKey&Valueを返さないAPIなどもありえます。
Dog.json
[ { "name": "Spring", "vaccinations": null } ]
この場合、デコードエラーになるのでパースエラーのときにはvaccinationsに空配列をセットする実装を追加しましょう
Dog.swift
struct Dog: Decodable { let name: String let vaccinations: [Vaccination] init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) name = try values.decode(String.self, forKey: .name) do { vaccinations = try values.decode([Vaccination].self, forKey: .vaccinations) } catch { vaccinations = [] } } private enum CodingKeys: String, CodingKey { case name case vaccinations } }
問題点
do - try - catch
が冗長- パラメータが増える度に
do - try - catch
を実装する必要がある - decodeの型を明示的に指定する必要がある
ということで
失敗時のデフォルト値を指定しつつ、型推論でよしなに型を指定してくれる実装をしたいと思います。
KeyedDecodingContainerを拡張する
decoder.container(keyedBy: CodingKeys.self)
で取得できる型の拡張を書くのが手っ取り早いです
KeyedDecodingContainer+Helper.swift
extension KeyedDecodingContainer { func decode<ResultType: Decodable>(forKey key: Key, defaultValue: ResultType? = nil) -> ResultType? { do { return try decode(ResultType.self, forKey: key) } catch let error { NSLog(error) return defaultValue } } }
これで5行が1行になりました
Dog.swift
struct Dog: Decodable { let name: String let vaccinations: [Vaccination] init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) name = try values.decode(String.self, forKey: .name) vaccinations = values.decode(forKey: .vaccinations) ?? [] } private enum CodingKeys: String, CodingKey { case name case vaccinations } }
2. String以外で表現できるプリミティブな型がStringで渡ってくる場合のフォールバック
次に犬の名前と年をパースする例を上げてみます。
Dog.json
[ { "name": "Spring", "age": "10" } ]
Dog.swift
struct Dog: Decodable { let name: String let age: Int init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) name = try values.decode(String.self, forKey: .name) age = values.decode(forKey: .age) ?? 0 } }
age
がこれでは0になってしまいます。
ここで
- StringからIntにパースする
- それでも失敗したらデフォルト値にする
という要件をKeyedDecodingContainerのextensionで実装してみます
KeyedDecodingContainer+Helper.swift
extension KeyedDecodingContainer { func decodeWithString<ResultType: FallbackableResultType>(forKey key: Key, defaultValue: ResultType? = nil) -> ResultType? { do { return try decode(ResultType.self, forKey: key) } catch let error { NSLog(error) do { let string = try decode(String.self, forKey: key) return ResultType.toResultType(string: string, defaultValue: defaultValue) } catch let error { NSLog(error) return defaultValue } } } } internal protocol FallbackableType { static func toResultType(string: String, defaultValue: Self?) -> Self? } typealias FallbackableResultType = FallbackableType & Decodable extension Int: FallbackableType { internal static func toResultType(string: String, defaultValue: Int?) -> Int? { return Int(string) ?? defaultValue } }
これで、無事、デコードできるようになりました。
肝としては2点
- 最初に各型にデコードできるかトライ
age: 10
だった場合でも処理できるのでAPIを修正した場合にバグにならない
- FallbackableTypeを用意して、String→各型への変換処理を移譲している
- FallbackableTypeに準拠した型に制限していますので、Stringから該当する型に変換する戦略を書く必要があります。
まとめ
ということで、KeyedDecodingContainer
を拡張すればデコード処理は簡潔に書ける〜という話でした
汎用的なAPIClientの設計と実装
TL; DR
Swift4でDecodableを使いつつ、
フレキシブルなAPIレスポンスの設計をしていったら結局APIクライアント書いてたという話。
その設計・実装の流れを綴りました。
長くて読みきれないっていう場合はソースコード読んでもらったほうがいいと思います。
利用ライブラリ
- Alamofire
- Result
- RxSwift
今回は上記を用いてGithubのSearch APIのクライアントを書いてみました
APIClientの抽象化ポイント
まずは抽象化したいポイント、いわゆるジェネリクスで振る舞いの差し替え可能なポイントをさがします。
- リクエスト
- エンドポイント
- パラメータ
- レスポンス
- パース
- エラー
上記の通り、APIクライアントはリクエストに対し対応するレスポンスがあるという点がジェネリクスとの相性がいいです。
リクエストから抽象化していきましょう
リクエスト
protocol Request { var endpoint: URL { get } var parameters: Alamofire.Parameters { get } var responseFormat: ResponseFormat { get } var encoding: ParameterEncoding { get } }
リクエストはこのようにエンドポイントとパラメータを抽象化します。
responseFormat
はenumで対象とするフォーマットを列挙しておきます。
今回はレスポンスの型にDecodableを使うので、期待しているレスポンスのフォーマットに応じてデコーダーを差し替える必要があるので、
予めリクエストに仕込むのがいいです。
まずはエンドポイントから
protocol Endpoint { var endpoint: URL { get } var root: URL { get } var path: String { get } }
URLの構成としてはルートが有り、そちらにパスを付けてURLを完成する形式が汎用的でよいです。
// MARK: - GitHubAPIEndpoint protocol GitHubAPIEndpoint: Endpoint { var functionPath: String { get } } extension GitHubAPIEndpoint { var root: URL { return URL(string: URLConstants.GithubAPIURLRoot)! } var endpoint: URL { return root.appendingPathComponent([functionPath, path].joined(separator: "/")) } } enum GithubSearchAPIEndpoint: String, GitHubAPIEndpoint { case repositories var path: String { return self.rawValue } var functionPath: String { return "search" } }
GithubのAPIはroot+[機能]+[機能を絞ったパス]
という設計なので、functionPathを追加しています。
そこで、機能毎にenumを定義することで[機能を絞ったパス]
はenumにまかせています。
パラメータの抽象化とenumで扱えるようにする
protocol ParameterKey: Hashable { var key: String { get } } protocol AlamofireParameters { var parameters: Alamofire.Parameters { get } } protocol Parameter { associatedtype Key: ParameterKey var parameter: [Self.Key: Any] { get set } mutating func setParameter(_ value: Any, forKey key: Key) } extension Parameter { mutating func setParameter(_ value: Any, forKey key: Key) { parameter[key] = value } mutating func setParameter<T: RawRepresentable>(_ value: T, forKey key: Key) where T.RawValue == String { parameter[key] = value.rawValue } } extension AlamofireParameters where Self: Parameter { var parameters: Alamofire.Parameters { return parameter.associate { (key, value) in return (key.key, value) } } }
検索APIのパラメータは下記の通り
struct SearchAPIParameter: Parameter { var parameter: [SearchAPIParameterKey : Any] = [:] typealias Key = SearchAPIParameterKey init() {} enum SearchAPIParameterKey: String, ParameterKey { case q case sort case order var key: String { return self.rawValue } } } extension SearchAPIParameter: AlamofireParameters {}
varr parameter = SearchAPIParameter() parameter.setParameter(q, forKey: .q)
こんな感じで、クエリがenumで指定できるようにします。
これで、パラメータとエンドポイントをprotocol化して汎用的なリクエストを作ることができるようになりました。
クライアント
基本方針はGenericsによって返り値の型を特定し、結果がマップされるようにします。
protocol APIClient { var sessionManager: SessionManager { get } func get<Response: Decodable>(apiRequest: Request) -> Observable<Response> } internal extension APIClient { internal func _get<Response>(apiRequest: Request) -> Observable<Response> where Response: Decodable { return request(method: .get, apiRequest: apiRequest) } } private extension APIClient { private func request<Response: Decodable>(method: HTTPMethod, apiRequest: Request) -> Observable<Response> { return Single.create { observer in weak var request = self.sessionManager.request(apiRequest.endpoint, method: method, parameters: apiRequest.parameters, encoding: apiRequest.encoding) request? .validate() .responseData(queue: DispatchQueueManager.shared.queue) { self.handleResponse(response: $0, observer: observer) } return Disposables.create { request?.cancel() } }.asObservable() } private func handleResponse<Response: Decodable>(response: DataResponse<Data>, observer: ((SingleEvent<Response>) -> Void)) { guard let data = response.data else { return observer(.error(APIError())) } let jsonDecoder = JSONDecoder() do { let parsed = try jsonDecoder.decode(Response.self, from: data) observer(.success(parsed)) } catch let error { observer(.error(error)) } } }
基本形はこのようになります。
これで各クライアントに APIClient
のプロトコルを付加すれば通信できるようになりました
また、戻り値の中身であるResponse
にDecodableである制約を設けています。
こうすることによってパースの戦略は各Responseに任せつつ、Responseの型が差し替え可能になるので
拡張に開くことができています。
レスポンスのハンドリング
APIの中身をパースして異常だった場合にそのハンドリングをしたいケースもありえますが
ただ、Response
型に欲しい型を指定した場合にそのインターセプトができません、
また、ベースとなるAPIのレスポンスのフォーマットがある場合何度も書くのは面倒です
例えば、下記の場合、Itemsの中身だけがT型として変わる場合です。
{ total_count: 841, incomplete_results: false, items: [ {} ] }
そこで、
- Result型でsuccess
, failureを透過的に扱う - Result型からsuccess
の時はResultからResponseをアンラップする
という要件が出てきます
まずはItemのような差し替え可能な型を内包するProtocolを定義します
protocol BasicAPIResponse: Decodable { associatedtype ResponseT: Decodable }
次に、Githubに特化したベースとなるレスポンスの型を定義します
こんな感じで、T(itemsの型)を差し替え可能なようにしておきます
struct GithubAPIResponseBase<T: Decodable>: BasicAPIResponse { typealias ResponseT = T typealias ErrorT = APIError private let total_count: Int private let incomplete_results: Bool private let items: ResponseT? }
次に結果をResultで受け取れるようにします
Result型はsuccess, failureの2択で結果を透過的に扱えるようにしているenumのライブラリです
protocol APIResult { associatedtype ResponseT: Decodable associatedtype ErrorT: Swift.Error var result: Result<ResponseT, ErrorT> { get } } extension GithubAPIResponseBase: APIResult { var result: Result<ResponseT, APIError> { guard let response = items else { return .failure(APIError()) } return .success(response) } }
これで、特別なAPIのパース処理を書きたい時はGithubAPIResponseBase
のイニシャライザを用意すれば良くなりました。
しかしながらResult型はAPIの結果なのでクライアントを呼び出す側では意識したくありません。
そこでClientでResultをアンラップする仕組みを考えます
ここで使えるのがType Erasureです
struct AnyAPIResult<ResponseT: Decodable, ErrorT: Swift.Error>: APIResult { let _result: () -> Result<ResponseT, ErrorT> init<Base: APIResult> (_ base: Base) where Base.ResponseT == ResponseT, Base.ErrorT == ErrorT { _result = { () -> Result<ResponseT, ErrorT> in return base.result } } var result: Result<ResponseT, ErrorT> { return _result() } } protocol AnyAPIResultConvartibleType: APIResult { var anyAPIResult: AnyAPIResult<Self.ResponseT, Self.ErrorT> { get } } extension AnyAPIResultConvartibleType { var anyAPIResult: AnyAPIResult<Self.ResponseT, Self.ErrorT> { return AnyAPIResult(self) } }
TypeErasureを使ってAnyAPIResultを定義することで、
extension GithubAPIResponseBase: AnyAPIResultConvartibleType {}
GithubAPIResponseBaseをAnyAPIResultConvartibleTypeとして扱うことができるようになりました。
結果的にResult<Response, Error>の型がわかっていなくても、Anyがそこを回避してアンラップ処理を共通化することができるようになりました
extension Observable { func unwrapResult<T, E>() -> Observable<T> where Element == AnyAPIResult<T, E> { return self .asObservable() .map { result in switch result.result { case .success(let t): return t case .failure(let e): return Observable.error(e) } } } }
最終的なGithubAPIClient
struct GithubAPIClient: APIClient { var sessionManager: SessionManager init() { sessionManager = Alamofire.SessionManager(configuration: URLSessionConfiguration.default) } } extension GithubAPIClient { func get<Response>(apiRequest: Request) -> Observable<Response> where Response : Decodable { typealias BasicResponse = GithubAPIResponseBase<Response> return _get(apiRequest: apiRequest) .map { (res: BasicResponse) in return res.anyAPIResult } .unwrapResult() } }
ということで、外から欲しい型を注入し、戻り値の型から欲しい型を決めてレスポンスをパースするということが実現できました。
Github Search APIをたたくサービスをつくる
あとは、Itemsの中身の型を定義して
struct GithubRepositoryDTO: Decodable { let id: Int //以下略 }
Clientを使うサービスを定義し、戻り値の型に戻り値としたい型を指定するだけです。
protocol APIService { var client: APIClient { get } } protocol SearchAPI: APIService { func searchRepository(q: String) -> Observable<[GithubRepositoryDTO]> } struct SearchAPIService: SearchAPI { var client: APIClient { return _client } var _client = GithubAPIClient() func searchRepository(q: String) -> Observable<[GithubRepositoryDTO]> { var parameter = SearchAPIParameter() parameter.setParameter(q, forKey: .q) return client.get(apiRequest: APIRequest(apiEndpoint: GithubSearchAPIEndpoint.repositories, apiParameters: parameter, encoding: URLEncoding.default)) } }
まとめ
ということで、ほぼ、サービスやHTTPクライアントに依存しないインターフェイスができました。
APIClientの実装をAlamofireから別のHTTPクライアントにしたり、レスポンスをデコードする戦略を書き換えたりしても他の層には基本的に影響がない実装ができました。
ここまで、自分で読み返してもうまく説明できてる気がしないのでソースコードを見ることをおすすめします(笑)
Swift4のDictionaryのアプデの目玉が公式ブログ?に掲載されてたので読んでみた
原文はこちら
Swift4ではDictionaryがより便利に使えるようになっているということで サンプルを見ていく
Grouping Values By a Key
Swift3系では
グループ機能がなく、かなりの手順を要していた
// Swift <= 3.1 var grouped: [GroceryItem.Department: [GroceryItem]] = [:] for item in groceries { if grouped[item.department] != nil { grouped[item.department]!.append(item) } else { grouped[item.department] = [item] } }
- まずグループの配列があるかチェック
- あれば配列についか
- なければ配列を作って
grouped
に登録
これがSwift4ではDictionaryのイニシャライザに用意され、1行で書くことができるようになりました。
// Swift >= 4.0 let groceriesByDepartment = Dictionary.init(grouping: groceries, by: { item in item.department })
このまま紹介しても味気ないので僕は下記のように使ってみました
extension Sequence { func group<Key>(by predicate: (Element) throws -> Key) rethrows -> [Key: [Element]] { return try Dictionary(grouping: self, by: predicate) } } let groceriesByDepartment = groceries.group(by: { item in item.department }) // [seafood: [{…}, {…}], bakery: [{…}, {…}], produce: [{…}, {…}, {…}]]
grouping対象はSequenceに準拠している必要があるということで、もはやSequeceに用意しちゃえばいいじゃん!と。
Transforming a Dictionary’s Values
Swift3まではmapすると、(key, value)
形式で走査してましたがmapValue
を使えば、valueだけを走査することができるようになりました。
なお、transformの対象はValueのみ。
let nameByDepartment = groceriesByDepartment.mapValues { items in items.map { item in item.name } } // [seafood: ["Salmon", "Shrimp"], bakery: ["Croissants", "Bread"], produce: ["Apples", "Bananas", "Grapes"]]
Uniquing Keys With
let pairs = [("dog", "🐕"), ("cat", "🐱"), ("dog", "🐶"), ("bunny", "🐰")] let petmoji = Dictionary(pairs, uniquingKeysWith: { (old, new) in new }) // petmoji["cat"] == "🐱" // petmoji["dog"] == "🐶"
SequenceのElementの型がCompound Typeなのでこちらはextensionかけない…
Using Default Values
for item in [🍌, 🍌, 🍞] { cart[item, default: 0] += 1 }
下記でも解説されてますね
Merging Two Dictionaries Into One
var cart = [🍌: 1, 🍇: 2] let otherCart = [🍌: 2, 🍇: 3] cart.merge(otherCart, uniquingKeysWith: +)
この実装見ていて初めて気づいたんですが、closureとしてオペレータ渡せるんですね。
知らなかったので今後の実装に使えそう。
func +(left: T, right: T) -> T { }
冷静に考えると、+ operatorはinfixな関数なので、なるほどSwiftよくできてるな〜と感心した案件でした。
RxCocoaのUITableViewのbind(to: )にRegistrableを使って処理の簡略化
前提
Registrable型に則ればあとは型推論によるextensionの実装で済ませようというアプローチです
キャストが失敗したら?だったり、各型のIdentifierを取得する手間をextensionに閉じ込めることができるので、
シーケンスに集中することができるためコードの見通しが良くなります。
RxCocoaのBindToにこの仕組を使う
RxCocoaではbind(to)
でシーケンスなElementをUITableViewのデータソースとして扱い、それを表示する拡張があります。
下記がその実装になります。
public func items<S: Sequence, Cell: UITableViewCell, O : ObservableType> (cellIdentifier: String, cellType: Cell.Type = Cell.self) -> (_ source: O) -> (_ configureCell: @escaping (Int, S.Iterator.Element, Cell) -> Void) -> Disposable where O.E == S { return { source in return { configureCell in let dataSource = RxTableViewReactiveArrayDataSourceSequenceWrapper<S> { (tv, i, item) in let indexPath = IndexPath(item: i, section: 0) let cell = tv.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! Cell configureCell(i, item, cell) return cell } return self.items(dataSource: dataSource)(source) } } }
しかしこのままではRegistrableにしているのにIdentifierをまた書かなければなりません。そこで、Registrableの仕組みをextensionに内包してしまおうという発送です。
デフォルトの実装に対してRegistrableを使ったインターフェイスを定義して、処理を軽くラップすれば実現することができます。
実際の変更点としては2点です
- cellTypeのみを引数にとるようにジェネリクスの型を調整
- 内部で
cellIdentifier
にはRegistrable.reuseIdentifire
を使ってもとの実装に処理を委譲
UITableView+Rx.swift
extension Reactive where Base: UITableView { public func items<S: Sequence, Cell: UITableViewCell & NibRegistrable, O : ObservableType> (cellType: Cell.Type = Cell.self) -> (_ source: O) -> (_ configureCell: @escaping (Int, S.Iterator.Element, Cell) -> Void) -> Disposable where O.E == S { return self.items(cellIdentifier: cellType.reuseIdentifier, cellType: cellType.self) } }
こうすることで、tableViewの処理が4行(bind() {}で書いているところ)で収まります。
サンプル(ViewController.swift#L39-L42)
class ViewController: UIViewController { @IBOutlet weak var tableView: UITableView! let searchModel = GithubSearchModel() let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() tableView.register(SearchResultCell.self) bind() searchModel.search(q: "Rx") } } private extension ViewController { func bind() { searchModel.searchResult.asObservable() .bind(to: tableView.rx.items(cellType: SearchResultCell.self)) { (row, element, cell) in cell.setData(data: element) }.disposed(by: disposeBag) } }
上記の前提としては
- RegistrableでUITableViewCellの登録処理や取得処理をラップ
- Modelには検索結果を
Variable<ResultData>
として公開しておき、ViewController側わModelをSubscribeしておく
ただし、このbind(to:)
は複数のcellのタイプを使えないのでご注意を。。。
objc_getAssociatedObject で必ずnil返ってくる件(解決済み)
こちらのエントリーでExtensionPropertyをExtensionとしてつけるだけでassociated objectにアクセスしやすくするというエントリーを書きました
しかしながら、この記事にはバグがありました。
それは getProperty
が必ずnilになってしまい、デフォルト値が必ず返ってきていました。
原因
該当するコード
public extension ExtensionProperty { public func getProperty<K: ExtensionPropertyKey, V>(key: K, initialValue: V) -> V { var keyString = key.keyString if let variable = objc_getAssociatedObject(self, &keyString) as? V { return variable } setProperty(key: key, newValue: initialValue) return initialValue } public func setProperty<K: ExtensionPropertyKey, V>(key: K, newValue: V) { objc_setAssociatedObject(self, key.keyString, newValue, .OBJC_ASSOCIATION_RETAIN) } }
var keyString = key.keyString
Stringをコピーしていたため、インスタンスのメモリの番地が違ってしまっていた。結果的にキーが違うのでnilが返ってくるという理屈でした。
対応
本当はExtensionPropertyKeyのkeyStringの参照を渡すことができれば容易なのですが、 inout
な引数にletな値を渡すことはできません。
そこで、UnsafeRawPointer
を取得して渡すことで解決しました。これならenumをキーとして使いたいという当初の目的を達成しています。
internal extension ExtensionPropertyKey { var keyPointer: UnsafeRawPointer { return unsafeBitCast(self.keyString, to: UnsafeRawPointer.self) } } // MARK: - ExtensionProperty public extension ExtensionProperty { public func getProperty<K: ExtensionPropertyKey, V>(key: K, defaultValue: V) -> V { if let variable = objc_getAssociatedObject(self, key.keyPointer) as? V { return variable } setProperty(key: key, newValue: defaultValue) return defaultValue } public func setProperty<K: ExtensionPropertyKey, V>(key: K, newValue: V, policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN) { objc_setAssociatedObject(self, key.keyPointer, newValue, policy) } }
いや〜。ナマのポインタは最強ですね
UserDefaultsをSwiftのEnumで扱えるように拡張する
UserDefaultsって便利ですよね。
基本的にはユーザーのアプリ内の設定値保存に使うことを主としていますが、
キルされても保持したいけど、アンインストール→インストールでは消されてもいい。DBを作るまでもないといった
値の軽いキャッシュとして利用したりもできます。
UserDefaultsのキーはString...
Stringって安易に使うとバギーですよね。
どこかでKeyを集約書けばよくね?って思いますがそれってつまり列挙なんです。 ということで、enumでラップして使ってみました。
アプローチ
- キーのprotocol
UserDefaultKey
を定義 UserDefaultKey
を引数にとって各々の型の値を返すextensionを定義UserDefaultKey
を使ったUserDefaultのデータストアの実装
1. キーのprotocol UserDefaultKey
を定義
public protocol UserDefaultKey: RawRepresentable, CustomStringConvertible { } public extension UserDefaultKey where RawValue == String { public var description: String { return self.rawValue } }
Enumって実はよしなにコンパイラが作ってくれるEquatable
とRawRepresentable
に準拠しているstructのstaticな変数の列挙のようなものです。
そこでEnumを包含する方の定義としてRawRepresentable
を定義しておきます。
今回はCustomStringConvertible.description
をキーとして使う戦略です。
なので、RawValueがStringのときはdescriptionの値としてself.rawValue
を返せばいいことになります
したがって下記のようにenumを作るだけで、キーとして扱えるということです
enum DefaultKeys: String, UserDefaultKey { case autoReloadEnabled }
2. UserDefaultKey
を引数にとって各々の型の値を返すextensionを定義
UserDefaultKeyを受け取れるインターフェースとして定義しています。
これで透過的にUserDefaultKey
をキーとして扱えるようになりました
// MARK: getter public extension UserDefaults { public func object<Key: UserDefaultKey>(forKey key: Key) -> Any? { return object(forKey: key.description) } public func url<Key: UserDefaultKey>(forKey key: Key) -> URL? { return url(forKey: key.description) } public func array<Key: UserDefaultKey>(forKey key: Key) -> [Any]? { return array(forKey: key.description) } public func dictionary<Key: UserDefaultKey>(forKey key: Key) -> [String: Any]? { return dictionary(forKey: key.description) } public func string<Key: UserDefaultKey>(forKey key: Key) -> String? { return string(forKey: key.description) } public func stringArray<Key: UserDefaultKey>(forKey key: Key) -> [String]? { return stringArray(forKey: key.description) } public func data<Key: UserDefaultKey>(forKey key: Key) -> Data? { return data(forKey: key.description) } public func bool<Key: UserDefaultKey>(forKey key: Key) -> Bool? { return bool(forKey: key.description) } public func integer<Key: UserDefaultKey>(forKey key: Key) -> Int? { return integer(forKey: key.description) } public func float<Key: UserDefaultKey>(forKey key: Key) -> Float? { return float(forKey: key.description) } public func double<Key: UserDefaultKey>(forKey key: Key) -> Double? { return double(forKey: key.description) } } // MARK: - setter public extension UserDefaults { public func set<Key: UserDefaultKey>(_ value: Any?, forKey key: Key) { set(value, forKey: key.description) } public func set<Key: UserDefaultKey>(_ value: URL?, forKey key: Key) { set(value, forKey: key.description) } public func set<Key: UserDefaultKey>(_ value: Bool, forKey key: Key) { set(value, forKey: key.description) } public func set<Key: UserDefaultKey>(_ value: Int, forKey key: Key) { set(value, forKey: key.description) } public func set<Key: UserDefaultKey>(_ value: Float, forKey key: Key) { set(value, forKey: key.description) } public func set<Key: UserDefaultKey>(_ value: Double, forKey key: Key) { set(value, forKey: key.description) } }
3. UserDefaultKey
を使ったUserDefaultのデータストアの実装
protocol UserDefaultsDataStore { var autoReloadEnabled: String? { get set } } fileprivate enum UserDefaultsDataStoreKeys: String, UserDefaultKey { case autoReloadEnabled } struct UserDefaultsDataStoreImpl: UserDefaultsDataStore { var autoReloadEnabled: Bool { get { return bool(forKey: .autoReloadEnabled) ?? false } set { set(value: newValue, forKey: .authToken) } } private var defaults: UserDefaults { return UserDefaults.standard } } private extension UserDefaultsDataStoreImpl { func object(forKey key: UserDefaultsDataStoreKeys) -> Any? { return defaults.object(forKey: key) } func url(forKey key: UserDefaultsDataStoreKeys) ->URL? { return defaults.url(forKey: key) } func array(forKey key: UserDefaultsDataStoreKeys) ->[Any]? { return defaults.array(forKey: key) } func dictionary(forKey key: UserDefaultsDataStoreKeys) ->[String: Any]? { return defaults.dictionary(forKey: key) } func string(forKey key: UserDefaultsDataStoreKeys) ->String? { return defaults.string(forKey: key) } func stringArray(forKey key: UserDefaultsDataStoreKeys) ->[String]? { return defaults.stringArray(forKey: key) } func data(forKey key: UserDefaultsDataStoreKeys) ->Data? { return defaults.data(forKey: key) } func bool(forKey key: UserDefaultsDataStoreKeys) ->Bool? { return defaults.bool(forKey: key) } func integer(forKey key: UserDefaultsDataStoreKeys) ->Int? { return defaults.integer(forKey: key) } func float(forKey key: UserDefaultsDataStoreKeys) ->Float? { return defaults.float(forKey: key) } func double(forKey key: UserDefaultsDataStoreKeys) ->Double? { return defaults.double(forKey: key) } func set<V>(value: V?, forKey key: UserDefaultsDataStoreKeys) { if let v = value { defaults.set(v, forKey: key) defaults.synchronize() } } }
DataStore側でもgetterを定義していて若干冗長ですが、.autoReloadEnabled
のようにEnumを省略形で書くためそうしています。
今回はUserDefaultKey
というprotocolを用意しましたが
もし、enumは1個しか定義しないと仮定するならば
具象enumを引数に取るUserDefaultsのextensionを書けばいいのでもっと簡潔にはできると思います。
UIScrollViewのページングをライフサイクルとして扱えるようにする
もっといいタイトル無いだろうか・・・笑
利用用途
UIViewControllerのviewをページとしてUIScrollViewにマウントしておいて、スワイプで切り替えて使うみたいな想定です。
UIScrollViewでページャーのviewDidAppearみたいなライフサイクルがあれば表示されたViewControllerをリロードしたいときに使えます。
やること
方針
- RxSwiftでUIScrollViewのReactiveを拡張
- プロトコル指向な実装でUIViewControllerにロジックを実装しない
ベタベタに実装すればできるんですが、UIViewControllerから実装を切り分けることで、UIViewControllerをファットにならずに済むわけです
大まかな流れ
- ページが切り替わった
- 表示領域から該当するUIViewControllerの検出
- ライフサイクルメソッドの発行
実装
1. RxでUIScrollViewのページング完了を検出する
/// Page struct UIScrollViewPage { let vertical: Int let horizontal: Int } extension UIScrollViewPage: Equatable { public static func ==(lhs: UIScrollViewPage, rhs: UIScrollViewPage) -> Bool { return lhs.vertical == rhs.vertical && lhs.horizontal == rhs.horizontal } } /// Pageの切り替わりの検出 extension Reactive where Base: UIScrollView { /// ページが切り替わった場合に選択されたページを流すイベント発行します /// 慣性スクロールなしのスクロール停止 or 慣性スクロールが停止した場合にPageが選択されたと検出しています var: Observable<UIScrollViewPage> { return Observable.merge(didEndDragging.asObservable().filter { return !$0 }, didEndDecelerating.asObservable().map { _ in return true }) .map { _ in // 1以下(0)で割るとページが異常値になることがありうるのでフレームサイズが1以下のときは強制的に0ページ扱いにする let verticalPage = Int(self.base.frame.height < 1 ? 0 : self.base.contentOffset.y / self.base.frame.height) let horizontalPage = Int(self.base.frame.width < 1 ? 0 : (self.base.contentOffset.x / self.base.frame.width)) return UIScrollViewPage(vertical: verticalPage, horizontal: horizontalPage) } } }
extension UIScrollViewPage: Equatable {
Equatable
に準拠している理由はdidSelectedPage
のイベントをdistinctUntilChange
したいため。
2. バインディングするViewControllerのProtocolを定義する
protocol PagerScrollViewControllerProtocol: class { func setupPagerScrollView() var pager: UIScrollView { get } var pagerDelegateViews: [PageDelegate] { get } var disposeBag: DisposeBag { get } }
3. ページャーがあるUIViewControllerでPagerScrollViewControllerProtocolに準拠
class ViewController: UIViewController { var disposeBag = DisposeBag() @IBOutlet weak var scrollView: UIScrollView! override func viewDidLoad() { super.viewDidLoad() setupPagerScrollView() } } extension ViewController: PagerScrollViewControllerProtocol { var pager: UIScrollView { return scrollView } var pagerDelegateViews: [PageDelegate] { return childViewControllers.filter { $0 is PageDelegate }.map { $0 as! PageDelegate } } }
pagerのライフサイクルを伝えるViewの選択戦略はここで差し替えればOK
IBOutletで登録していたり、
var pagerDelegateViews: [PageDelegate] { return childViewControllers.filter { $0 is PageDelegate }.map { $0 as! PageDelegate } }
4. ページャーのライフサイクルを受け取るコンテンツのプロトコルを定義
protocol UIPageViewProtocol: class { var targetPageView: UIView { get } } protocol PageState: class { var isVisiblePage: Bool { get set } } protocol PageDelegate: PageState, UIPageViewProtocol { func viewDidAppear() func viewDidDisappear() }
targetPageView
はUIScrollViewの表示区域内にマウントされているかを検出する対象のViewです
ライフサイクルを受け取る側はPageDelegate
の拡張準拠実装すればOK
final class PageViewController: UIViewController { var isVisiblePage = false } extension PageViewController: PageDelegate { var targetPageView: UIView { return self.view } func viewDidAppear() {} func viewDidDisappear() {} }
5. PagerScrollViewControllerProtocolのデフォルト拡張でページング検知を実装
extension PagerScrollViewControllerProtocol where Self: UIViewController { /// ページ選択を検出するためのセットアップを行います /// ターゲットとなるScrollViewのイベントのバインディング /// 選択ステータスの初期化 func setupPagerScrollView() { pager.rx.didSelectedPage .distinctUntilChanged() .subscribe(onNext: { [weak self] page in guard let `self` = self else { return } self.applyAppearPage() }).disposed(by: disposeBag) // initialise for state applyAppearPage() } private func applyAppearPage() { let offset = pager.contentOffset let pagerSize = pager.frame.size let visibleArea = CGRect(x: offset.x, y: offset.y, width: pagerSize.width, height: pagerSize.height) pagerDelegateViews // 表示View, 非表示Viewの振り分け .reduce(into: Dictionary<Bool, Array<PageDelegate>>()) { base, content in let isVisisble = visibleArea.intersects(content.targetPageView.frame) base[isVisisble, default: []].append(content) } .forEach { (isVisisble, views) in if isVisisble { // 表示されていなかったViewがページ切り替えによって表示になった views .filter { page in return !page.isVisiblePage } .forEach { page in page.viewDidAppear() page.isVisiblePage = true } } else { // 表示されていたViewがページ切り替えによって非表示になった views.filter { page in return page.isVisiblePage } .forEach { page in page.viewDidDisappear() page.isVisiblePage = false } } } } }
まとめ
ということで、Rx拡張, Protocolのデフォルト拡張, 各Protocolに必要なプロパティを持たせるという実装に分けることで
各々がプロトコルのプロパティの部分だけ拡張実装すればOKという状態ができました。
UIKitがオブジェクト指向なので、UIKit周辺はオブジェクト指向な実装になりがちですが
こんな感じでプロトコル指向プログラミングできそうですね!
プロトコル指向プログラミングの紹介はこちら
SwiftでExtensionのプロパティの黒魔術感をなくす(追記アリ)
SwiftでExtensionに追加するプロパティの黒魔術感が異常。
クラス全体には関係ないけど、特定のextension内に閉じ込めたいpropertyが欲しくなることがあると思います。
しかし、Swiftではextensionローカルなプロパティを持とうとするとobjc_getAssociatedObject
を使うため、
急に黒魔術感が異常なほどに感じられるソースが出来上がります。
associatedObject
は設計で回避できますが、より簡潔にextensionにpropertyにアクセスしたいという場合に有効です。
Associated Objectをつかう
// Model.swift protocol Model { func load(id: String) } // Dog.swift class Dog { var name: String } // Dog+Model.swift fileprivate struct AssociatedKeys { static var disposeBagKey = "disposeBagKey" } extension Dog: Model { fileprivate(set) var disposeBag: DisposeBag { get { if let disposeBag = objc_getAssociatedObject(self, &AssociatedKeys.disposeBagKey) as? DisposeBag { return disposeBag } let initialValue = DisposeBag() objc_setAssociatedObject(self, &AssociatedKeys.disposeBagKey, initialValue, .OBJC_ASSOCIATION_RETAIN) return initialValue } set { objc_setAssociatedObject(self, &AssociatedKeys.disposeBagKey, newValue, .OBJC_ASSOCIATION_RETAIN) } } func load(id: String) -> Dog { api.load(id: id) .subscribe(onNext: { [weak self] model in self?.name = model.name }).disposed(by: disposeBag) } }
この実装がいけてるかは別としてこんな感じでdisposeBag
をfileprivate
でアクセスすることができます。
disposeBagをDogに持たなくて済むので近いところにデータを置くことができてハッピーです。
黒魔術にフタをする
ExtensionPropertyというものを用意します。またキーはenumで定義出来るといいので、RawRepresentable
かつ、RawValue == String
でkeyStringでアクセスすればいい感じにキーを取得できるようにしています。
// MARK: - ExtensionPropertyKey /// Extension内にセットするデータのキー public protocol ExtensionPropertyKey: RawRepresentable { var keyString: String { get } } // MARK: - ExtensionPropertyKey /// A default implementation for enum which is extend String public extension ExtensionPropertyKey where Self.RawValue == String { var keyString: String { return self.rawValue } } // MARK: - ExtensionProperty /// A strategy for manage extension local property. public protocol ExtensionProperty: class { func getProperty<K: ExtensionPropertyKey, V>(key: K, defaultValue: V) -> V func setProperty<K: ExtensionPropertyKey, V>(key: K, newValue: V) } // MARK: - ExtensionProperty public extension ExtensionProperty { public func getProperty<K: ExtensionPropertyKey, V>(key: K, initialValue: V) -> V { var keyString = key.keyString if let variable = objc_getAssociatedObject(self, &keyString) as? V { return variable } setProperty(key: key, newValue: initialValue) return initialValue } public func setProperty<K: ExtensionPropertyKey, V>(key: K, newValue: V) { objc_setAssociatedObject(self, key.keyString, newValue, .OBJC_ASSOCIATION_RETAIN) } }
実際につかってみる
// Dog+Model.swift fileprivate enum ExtensionPropertyKey: String, ExtensionPropertyKey { case disposeBag } extension Dog: Model { fileprivate(set) var disposeBag: DisposeBag { get { return getProperty(key: ExtensionPropertyKey.disposeBag, initialValue: DisposeBag()) } set { setProperty(key: ExtensionPropertyKey.disposeBag, newValue: newValue) } } }
こんな感じで、若干黒魔術感にフタをすることができました。
キーかぶりの可能性を排除する
現状ですと、disposeBag
が他の拡張で上書きされる可能性があります。また、型が違うキャストに失敗するのでバグを埋め込むことになります。そこで#file
, #line
の出番です
fileprivate enum ExtensionPropertyKey: String, ExtensionPropertyKey { case disposeBagKey var keyString: String { return [#file.description, String(#line), self.rawValue].joined(separator: "::") } }
こうすることで、ExtensionPropertyKey
が定義されているファイルのパスとソースの行番号が入るので絶対にかぶることはありません。
黒魔術感が復活した気がしますがこんな感じでextensionにpropertyを閉じ込められたので見通しも良くなりそうです。