しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

Property wrapperを使ってCodableの一部のプロパティをencodeしないようにする

最近リファクタをしていて、同じオブジェクトを使ってencodeとdecodeをする際に、必要なケースがでてきたので書きました。

状況

以下のHogeのように一部のパラメーターでdecodeはしたいが、なにかの事情でencodeしたくないがためにencode(to:)を実装している状況です。

struct Hoge: Codable {
    var id: Int
    var name: String
    var status: String // これだけencodeしたくない

    enum CodingKeys: String, CodingKey {
        case id
        case name
        case status
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(id, forKey: .id)
        try container.encode(name, forKey: .name)
    }
}

上のようにに書いた場合、パラメーターが増えるたびに、CodingKeysencode(to:)に付け足さなければならなくなるので大変です。

Property wrapperを使った解決

Property wrapperを使ってwrapして、そちらをCodableに準拠させることで楽に解決できます。

@propertyWrapper
struct DecodeOnly<T: Decodable> {
    var wrappedValue: T
}

extension DecodeOnly: Codable {
    init(from decoder: Decoder) throws {
        self.wrappedValue = try T(from: decoder)
    }

    func encode(to encoder: Encoder) throws {}
}

DecodeOnlyというwrapperをCodableにして、encodeの処理は空にしておくことで、decodeのみ行うことができます。(TEncodableである必要はありません。)

ただし、このままだとvalueは空になるのですが、"status": {}のようにkeyは残ってしまうので、KeyedEncodingContainerの方も処理をskipするようにします。

public extension KeyedEncodingContainer {
    func encode<T: Decodable>(_ value: DecodeOnly<T>, forKey key: K) throws {}
}

こうしておけば、上の例のHogeは、

struct Hoge: Codable {
    var id: Int
    var name: String
    @DecodeOnly var status: String
}

のように簡単に書くことが出来ます。

実際にJSONEncoderでencodeしてみると、statusが含まれていないのがわかります。

let hoge = Hoge(id: 12, name: "aaa", status: "yeah")
let encoded = try! JSONEncoder().encode(hoge)
print(String(data: encoded, encoding: .utf8)!) // {"id":12,"name":"aaa"}

Equatableへの応用

似たようなケースで、一部のプロパティだけEquatableで比較したくないといったケースにも対応できます。

struct Fuga: Equatable {
    var id: Int
    var name: String
    var comment: String // これだけ比較したくない

    static func == (lhs: Hoge, rhs: Hoge) -> Bool {
        return lhs.id == rhs.id && lhs.name == rhs.name
    }
}

同じように、property wrapperをEquatableに準拠させて解決します。

@propertyWrapper
struct AlwaysEqual<T> {
    var wrappedValue: T
}

extension AlwaysEqual: Equatable {
    static func == (lhs: AlwaysEqual<T>, rhs: AlwaysEqual<T>) -> Bool {
        return true
    }
}

これを使って、==の実装を省略できます。

struct Fuga: Equatable {
    var id: Int
    var name: String
    @AlwaysEqual var comment: String
}
let fugaA = Fuga(id: 1, name: "aaa", comment: "apple") 
let fugaB = Fuga(id: 1, name: "aaa", comment: "banana")
print(fugaA == fugaB) // true

サンプルコード

こちらにおいてあります👇

よくある実装でDateComponentsFormatterやRelativeDateTimeFormatterを使う

DateFormatterに関しては古くからあり、おおよそのプロジェクトで使われているイメージですが、DateComponentsFormatterRelativeDateTimeFormatter(iOS13+なので最近)については体感としてあまり使われておらず、特に歴史のあるプロジェクトでは独自のformatterで実装しているケースが多いので、書いてみました。

全てのケースで使えるわけではないですが、特に多言語対応しているアプリでは、大きく実装量が減って楽にはなるので、選択肢の一つとしてあり得ると思います。

標準のFormatterを使う意義

独自のformatterを利用するのに対して、標準のformatterを使うことの一番のメリットは、言語ごとに追加で実装することなく、他の言語での表記方法に対応できるところです。

これについては、昨年のWWDCの発表が詳しいですが、こちらではdate関連以外のformatterについても紹介されています。

developer.apple.com

独自のformatterは、一度書いてしまえば動きが変わることが少ない部分ではあるので、運用面では問題ないと思います。
しかし、なんだかんだテストを書いたり、分岐を多く書かないといけないケースもあるので、使える際には標準のものを使いたいところです。 今回のformatterで言えば、TimeIntervalも引数に取れるので、60で割る処理を書かなくて済むケースもあり得ます。

デメリットとしては、標準で用意された表記方法をしたい場合に小回りが利かないという点があます。 例えばあるケースだけ違う表記をしたいという場合は、大体個別に処理を書く必要があります。

使用例

実際にありそうなケースで、DateComponentsFormatterRelativeDateTimeFormatterを使ってみます。

Case 1: 残り時間のカウントダウン

ECアプリのキャンペーンなどでよくある"残り3日"とか"残り8時間"のような表記です。 こちらはDateComponentsFormatterのみで実装できます。

let formatter = DateComponentsFormatter()
formatter.unitsStyle = .full
formatter.allowedUnits = [.day, .hour, .minute]
formatter.maximumUnitCount = 1
formatter.includesTimeRemainingPhrase = true

unitsStyleによって単位の表記の方法が変わります。(hours, hrs, hなど)

https://developer.apple.com/documentation/foundation/datecomponentsformatter/unitsstyle

allowedUnitsで指定した日, 時, 分のみが利用され、maximumUnitCountが1なので一番大きな単位のみが表示されます。 includesTimeRemainingPhraseをつけることで"残り"の部分も各言語に合わせてつけてくれます。

print(formatter.string(from: 36)!) // 残り0分, 0 minutes remaining
print(formatter.string(from: 192)!) // 残り3分, 3 minutes remaining
print(formatter.string(from: 10922)!) // 残り3時間, 3 hours remaining
print(formatter.string(from: 90000)!) // 残り1日, 1 day remaining

残り時間によって表記が変わるなど、こだわったviewでなければ、これはそのまま使えそうです。

Case 2: 再生時間の表示

動画アプリや音楽アプリの再生時間などである1:52のような表記です。

言語によらない表記のためメリットが薄いのと、後述の問題があるので、独自実装でも良いケースではありますが、動画や音楽の再生時間や残り再生時間を表示する際もDateComponentsFormatterが利用できます。

1:52のような形式で表示する際はpositionalを利用します。

let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional // defaultでpositional
formatter.allowedUnits = [.minute, .second]

01:52のようにゼロ埋めをする場合は、zeroFormattingBehaviorを利用します。

formatter.zeroFormattingBehavior = .pad
print(formatter.string(from: 36)!) // 00:36
print(formatter.string(from: 192)!) // 03:12
print(formatter.string(from: 10922)!) // 182:02

zeroFormattingBehaviorを設定した際は負数を入れた際に、想定した挙動にならないケースがあるので、YouTubeのような-01:52のような表示をしたい場合は注意が必要です。

print(formatter.string(from: -36)!) // 00:36 (-00:36にはしてくれない)
print(formatter.string(from: -192)!) // -03:12

また、0埋めの0の個数を変えられなかったり、"時間は省いて良いが、分は必ず表示させる"ような挙動を実現できなかったりするので、その場合は素直に独自のformatterを使ったり、複数のformatterを組み合わせるのが良さそうです。

Case 3: 投稿時間の相対表記

Instagramなどの投稿型のアプリでよく見る"n分前"や"n日前"といった表記は、RelativeDateTimeFormatterで実現できます。

let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .named
formatter.unitsStyle = .full

dateTimeStylenamedにすると、"1 day ago", "1 week ago"の代わりに"yesterday", "last week"などの表記をしてくれます。 正負で未来か過去かを判断してくれます。

print(formatter.localizedString(fromTimeInterval: -3)) // 3 秒前, 3 seconds ago
print(formatter.localizedString(fromTimeInterval: -192)) // 3 分前, 
3 minutes ago
print(formatter.localizedString(fromTimeInterval: -10802)) // 3 時間前, 3 hours ago
print(formatter.localizedString(fromTimeInterval: -90000)) // 昨日, yesterday

古すぎる場合に日時をそのまま表示をしたいケースでは、別途DateFormatterと組み合わせることになります。また、DateComponentsFormatterと違い、allowedUnitsのようなプロパティがないので、"nヶ月前"だけ"n日前"に直すみたいなことができません。

また、文中で使う場合などは、formattingContextを設定します。

formatter.formattingContext = .beginningOfSentence

この場合Yesterdayのように先頭を大文字にして返してくれます。

Twitterのように"n分"、"n日"の表記であれば、単に先ほどのDateComponentsFormatterを使うケースも考えられます。
先ほどのカウントダウンとほぼ同じで、

let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
formatter.allowedUnits = [.day, .hour, .minute, .second]
formatter.maximumUnitCount = 1

のような設定にすれば、

print(formatter.string(from: 36)!) // 36秒, 36s
print(formatter.string(from: 192)!) // 3分, 3m
print(formatter.string(from: 10922)!) // 3時間, 3h 
print(formatter.string(from: 90000)!) // 1日, 1d
print(formatter.string(from: 7257600)!) // 84日, 84d

このような結果が得られます。

RelativeDateTimeFormatterは未来の日時についても"tomorrow", "in n days"などの表記をしてくれるので、そこまでformatterに任せるかで使い分けができそうです。

余談

最近Public PreviewになったVSCode for the Webを使って書きましたが、markdownのプレビューも使えて、デスクトップ版と変わらない操作感で便利でした。

potatotips #74に参加して発表しました

6月23日(水)にPotatotips #74に参加させていただき、iOS枠で発表しました。

potatotips.connpass.com

ちょうどWWDCの後だったこともあり、SharePlayやGroup Activitiesなど、自分がキャッチアップできていない部分のお話を聞けて、とても楽しかったです。

Androidの内容についても、OSアップデートのお話でさまざまな事情があるということを聞けて、大変勉強になりました。

発表内容

「LLDBを活用したデザインチェック」という内容で発表しました。

反省として題材選びが悪く、削ったにもかかわらず、ちょっとボリュームが大きかったです。

speakerdeck.com

スライドの補足

蛇足ながら、削った内容を少し補足します。

SwiftとPythonスクリプトのデータのやり取り

SwiftではC++などと異なり、いわゆるスマートポインタが基本的に使われるので、raw pointerを使うためにはUnsafePointerの類を使う必要があります。(この点でObjC++で記述する方が楽な場面が多いです。Swiftで書いている例が圧倒的に少ない...)

そのため、スライドのようにPython側からメモリにwriteする際は、mallocのかわりにUnsafeMutablePointer<T>.allocateで確保して、そこ(pointee)からアドレスを取得して利用します。

(サンプルコードのGetObjectDescription()はSwiftのdebugDescriptionのようなもので, 正攻法ではないのですが、Swiftで書く以上仕方ない場面もあります。)

逆にメモリからreadする際にも、SwiftコードでwithUnsafePointerなどを利用すれば多少safeに書くことができます。

letを利用したLLDB上での変数定義

expressionコマンドで$を変数名の先頭につければ、変数を定義することができますが、varでもletでもimmutableになってしまいます。(expressionした時点ですでにvarletの情報は失われ、LLDBのValueに置き換わっている。)

このため、Python側で変数名を一意にする方法を使っています。

他の言語で書く際は、$R1などに相当する、自動で格納されるvalueを使うのが一般的です。(アドレス周りで苦戦しそうですが、Swiftでもスマートにいけるかもしれない...?)

ファイルの読み込みと書き込み

FileManagerを利用して、Swift側でファイルの読み書きをすることでPythonスクリプトの記述を減らすこともできますが、アクセスできる領域に制約が生じます。

Python側でファイルを読み書きすることで、実機でデバッグする際などにもPCのストレージのファイルを利用することができます。