クラス/構造体のプロパティ
さて、今回次のような仕様を持つアプリを作りたいとする。
- いくつかの API をコールしてレスポンスを取得する
- 取得したレスポンスを表示する
- コールする API のパラメータを設定できる
これだけだと非常に簡単である。API ごとにパラメータ設定のビューを作成し、そのビューでパラメータを設定したあとで何らかのボタンを押せばリクエストが投げられるようにすれば良い。
ただし、この愚直な方法が有効なのは API の数がたかが知れている場合のみである。もしも API のエンドポイントが 100 や 200 になればそれぞれのエンドポイントのためだけにビューを作成するのは手間がかかるし無駄である。
一つのビューだけで様々な API に対して対応できるようなオブジェクティブ指向のプログラミングがより相応しい。
問題を簡単にするため、今回は二つのエンドポイントに対応するビューを構成することを考えた。
二つのエンドポイント
まず、A というエンドポイントで指定された時間内のリザルトのresultId
を返す。
そして、B というエンドポイントでresultId
を指定してそのデータの詳細にアクセスするような仕組みである。
# A
- endpoint # /results
- userId
- startTime
- endTime
# B
- endpoint # /result/{resultId}
- userId
- resultId
これらをコード化すると大雑把に以下のようになる。
class UserResultList: Codable {
var path: String = "/results"
let userId: String
let startTime: Date
let endTime: Date
init(userId: String, startTime: Date, endTime: Date) {
self.userId = userId
self.startTime = startTime
self.endTime = endTime
}
}
class UserResult: Codable {
let path: String
let userId: String
init(userId: String, resultId: Int) {
self.path = "/result/\(resultId)"
self.userId = userId
}
}
クラスのプロパティを取得する
クラスのプロパティを取得するには色々方法があるのだが、一つはMirror
を利用するものです。
Mirror を利用する方法
【Swift 5.x】クラス/構造体のプロパティ名を取得するが大変参考になりました。
- インスタンスが必要
import Foundation
class UserResultList: Codable {
var path: String = "/results"
let userId: String
let startTime: Date
let endTime: Date
init(userId: String, startTime: Date, endTime: Date) {
self.userId = userId
self.startTime = startTime
self.endTime = endTime
}
// Mirror.Childrenを辞書に変換
var properties: [String: String] {
Mirror(reflecting: self).children
.filter({ $0.label != .none })
.reduce(into: [:]) {
$0[$1.label!] = $1.value as? String
}
.compactMapValues({ $0 })
}
}
注意点としてはインスタンスがないとMirror
は利用できないという点です。つまりstatic var
やclass var
は利用できません。
再利用性を高める
このままだとUserResultList
とUserResult
のどちらにもproperties
を定義しなければいけずめんどくさいのでプロトコルを使ってこれを解消します。
protocol RequestType: Codable {
}
extension RequestType {
var properties: [(key: String, value: Any)] {
return Mirror(reflecting: self).children
.filter({ $0.label != .none })
.reduce(into: [:]) {
$0[$1.label!] = $1.value
}
.compactMapValues({ $0 })
.sorted(by: { $0.0 > $1.0 })
}
}
まず、RequestType
プロトコルを作成しUserResult
とUserResultList
がこのプロトコルに適合するようにします。
class UserResultList: RequestType {
}
class UserResult: RequestType {
}
こうすることでどちらのクラスでもproperties
のプロパティが使えるようになりました。
プロパティを表示するビュー
ただ定義しただけではどのように動いているかわからないので、中身を表示するように以下のようなプロパティビューワーを作成します。
struct PropertyView: View {
@State var request: RequestType
@State var toggle: Bool = false
@State var stepper: Int = 0
var body: some View {
ForEach(request.properties, id:\.key) { key, value in
HStack {
Text(key)
Spacer()
Text(value as? String ?? "-")
.foregroundColor(.secondary)
}
}
}
}
::: warning 型の問題
クラスのプロパティは単にString
型だけではなくInt
型やDate
型やBool
型など様々なものが考えられる。
それら全てに本来は対応しなければいけないのだが、今回はとりあえずString
型のみ考え、String
型にキャストできないプロパティについては-
で表示することとした。
:::
これを利用すればContentView
を次のように定義できます。
struct ContentView: View {
@State var requests: [RequestType] = [
UserResultList(userId: "tkgling", startTime: Date(), endTime: Date()),
UserResult(userId: "tkgling", resultId: 0)
]
var body: some View {
Form {
ForEach(requests, id:\.path) { request in
Section(header: Text(String(describing: type(of: request)))) {
PropertyView(request: request)
}
}
}
}
}
::: warning プロトコルの配列を ForEach する
プロトコルの配列はそのままでは ForEach でループさせることができない。何故なら、プロトコルの配列は順序というものが定義できず、一意性が保証されないためだ。基本的にはIdentifiable
に適合させる必要があるのだが、Identifiable
に適合するとtypeAlias
が必要になり今度はプロトコルを適合したインスタンスを配列にできなくなる。
よってIdentifiable
に適合させずに ForEach を利用する方法を考えなければいけない。このときに利用できるのがid
でこれを使ってユニークなプロパティを指定して一意性を強制的に保証する。今回の場合だとpath
は必ず全てのRequestType
適合のクラスで異なるはずなのでこれを指定した。
:::
値を変更できるようにする
とはいえ、このままでは単にインスタンスに設定されている値を表示しているだけなのでその値を変更できるようにしましょう。
SwiftUI は構造体なので単に変数を指定しても値を更新することができません。よって値を SwiftUI フレームワークで管轄できるようにState
の Property Wrapper を設定する必要があります。
ところがここで気になるのはPrpertyView
が受け取ったリクエストによってどんなパラメータを設定するかが異なるという点です。つまり、予め「String 型のプロパティが 5 つあるから、5 つの変数を用意しておこう」といったことができません。
更に困ったことにRequestType
はuserId
とpath
しか定義していないためrequest.startTime
のようにアクセスすることができません。
そこで@DynamicMemberLookup
という機能を使ってみます。
DynamicMemberLookup
DynamicMemberLookup
は簡単に言えばKeyPath
を使ってクラスや構造体が持つプロパティのプロパティにアクセスする方法を指します。
protocol UserType {
var id: Int { get set }
var userName: String { get set }
var rank: UserRank { get set }
}
class UserRank {
var rankId: Int = Int.random(0 ... 100)
var rankName: String = "Intern"
init() {}
init(rankId: Int, rankName: String) {
self.rankId = rankId
self.rankName = rankName
}
}
class UserInfo: UserType {
var rank: UserRank = UserRank()
var id: Int = 17
var userName: String = "tkgling"
var isMembership: Bool = false
init() {}
init(id: Int, userName: String, isMemebership: Bool) {
self.id = id
self.userName = userName
self.isMembership = isMemebership
}
}
便利さを実感するために、まずはUserInfo
クラスに追加でUserRank
のインスタンスをもたせます。
ここで、それぞれのユーザのUserRank
のプロパティにアクセスする場合には、例えば次のようにしなければいけません。
for user in users {
print(user.rank.rankId) // -> 71, 30
}
で、これはネストが深くなれば深くなるほどプロパティの参照が続いてコードとして美しくなくなってしまいます。
使い方
やることは簡単で、まずは適用したいクラス、構造体、プロトコルに対して@dynamicMemberLookup
をつけます。
@dynamicMemberLookup
protocol UserType {
var id: Int { get set }
var userName: String { get set }
var rank: UserRank { get set }
}
extension UserType {
subscript<T>(dynamicMember keyPath: KeyPath<UserRank, T>) -> T {
rank[keyPath: keyPath]
}
}
そしてExtension
でsubscript
を定義します。これがないとコンパイルエラーが発生します。
subscipt
はいろいろな定義ができるのですが、
subscript<T>(dynamicMember keyPath: KeyPath<XXXXXXXX, T>) -> T {
YYYYYYYY[keyPath: keyPath] // XXXXXXXX型のプロパティYYYYYYYYを指定
}
の書き方が良いかと思います。
注意点としてはYYYYYYYY
は存在するプロパティでないとだめだということです。
実行してみる
var users: [UserType] = [UserInfo(), PlayerInfo()]
for user in users {
print(user.rankId) // user.rank.rankIdにアクセスしているのと同等
}
すると今度はuser.rankId
だけでuser.rank.rankId
にアクセスできてしまいました。
ここで大事になるのはuser.rankId
というのはどこにも定義されていないということです。本来であれば Swift は静的解析を行なうのでこのような書き方はコンパイルエラーが発生するのですが、@DynamicMemberLookup
をつけることでそのようなエラーが発生しないようにしているというわけです。
ちょっぴり黒魔術っぽい感じがしますね。
注意点
@dynamicMemberLookup
protocol UserType {
var id: Int { get set }
var userName: String { get set }
var rank: UserRank { get set }
var colorType: Color { get set }
}
extension UserType {
subscript<T>(dynamicMember keyPath: KeyPath<UserRank, T>) -> T {
rank[keyPath: keyPath]
}
subscript<T>(dynamicMember keyPath: KeyPath<Color, T>) -> T {
colorType[keyPath: keyPath]
}
}
このように複数のsubscript
を定義することもできます。
このとき注意しないといけないのはUserRank
とColor
のプロパティ名が被ってしまうとDynamicMemberLookup
が使えなくなることです。
class UserRank {
var rankId: Int = Int.random(in: 0 ... 100)
var rankName: String = "Intern"
var description: String = "UserRank Class" // 追加
init() {}
init(rankId: Int, rankName: String) {
self.rankId = rankId
self.rankName = rankName
}
}
例えば、UserRank
クラスに新たなプロパティdescription
を追加します。実はこのプロパティはColor
クラスにも存在するので、
var users: [UserType] = [UserInfo(), PlayerInfo()]
for user in users {
print(user.rankId)
print(user.description) // Ambiguous use of 'subscript(dynamicMember)'
}
というエラーが発生し「どちらの subscript を使えばよいかわからない」という内容が表示されます。
プロパティ名は被らないようにするか、被ったときにはちょっとめんどくさいですがuser.rank.description
のようにどちらかを明示するようにしましょう。
計算プロパティ
@dynamicMemberLookup
protocol UserType {
var id: Int { get set }
var rank: UserRank { get set }
}
extension UserType {
var colorType: Color {
Color.yellow
}
subscript<T>(dynamicMember keyPath: KeyPath<Color, T>) -> T {
colorType[keyPath: keyPath]
}
}
このように計算プロパティであっても正しく動作します。
配列で利用する
配列はオブジェクトではないため直接利用できないのですが、いろいろと奇妙な振る舞いが楽しめます。
@dynamicMemberLookup
protocol UserType {
var id: Int { get set }
var userName: String { get set }
var colorType: [Color] { get set }
}
extension UserType {
subscript<T>(dynamicMember keyPath: WritableKeyPath<[Color], T>) -> T {
return colorType[keyPath: keyPath]
}
}
このような感じで定義して、適当に値を代入してみると、
var users: [UserType] = [UserInfo(), PlayerInfo()]
for user in users {
print(user.colorType) // -> [red, blue, gray]
print(user[0]) // -> red
}
なんとuser[0]
にアクセスするとuser.colorType[0]
と同じ扱いになるので.red
が表示されました!