TechMTG文字起こしレポート:クックパッドマートのAndroidアプリのUI開発のこれまでとこれから

こんにちは、CTO室の緑川です。クックパッドでは隔週で全エンジニアが集まるTechMTGというミーティングを行っています。今回はTechMTGで話した技術的な取り組みや解説を文字起こしレポートとしてお届けします。

今回は4月19日に発表されたクックパッドマートでのAndroidアプリのUI開発についてです。 以下、レポート本文です。

クックパッドマートのAndroidアプリのUI開発のこれまでとこれから

こんにちは、門田です。 2016年に新卒入社をして2019年にクックパッドマートに移動し、今は買物プロダクト開発部というところでAndroidエンジニアをしています。

今回はAndroidアプリのUI開発の話をしようと思います。 Jetpack Composeの話がメインになると思っていたんですけど、去年の11月や先月にもJetpack Composeの話は社内でしていたので、今回はそんなに話すこともなくなったかなと思ったのですが、「クックパッドマートでJetpack Composeをどういうふうに使ってきたか?」という話はできていなかったと思うので、今回はそれを話そうかなと思っています。

アプリの実装で最も時間がかかる部分

最初に問題定義をしたいのですが、アプリの実装をするときに最も時間がかかるところって皆さんどこだと思いますか? 僕の中ではなんといってもUI開発が一番大きいと思っています。今回はこれを前提に話していこうかなと思います。

アプリの開発をしていく上で素早くUIを構築することが、素早く機能を開発していくことに最も寄与するんじゃないかなと思っています。じゃあそれをどうすれば実現できるのだろうというところを考えたときに、パターン化して簡単に誰でも早く開発できればいいんじゃないかということを考えて、クックパッドマートの開発初期から結構そのことを考えて実装してきました。

2019年にクックパッドマートを作り始めたときはAndroid View、いわゆるXMLを使って構築して、コードで味付けしていくみたいな方法が一般的だった時代なんですけど、この時にはRecyclerViewという要素をいっぱい並べるライブラリと、あとそれを簡単に使うためのGroupieっていうライブラリを使って複雑な画面の構築を簡単にしていくのをやっていました。これのメリットとしてはレイアウトファイル、たとえば商品詳細だったりとか、長いレイアウトのファイルでもある程度のまとまりごとに分割して組み立てていくことができるので、全体がシンプルに構築できるということに一つ大きなメリットがあります。

あとは一覧画面と詳細画面みたいなところで実装のパターン化ができて、これも実装速度の向上に大きく寄与をしたんじゃないかなと思っています。これに関しては当時テックブロックにも書いてたのでこれを参照してください。

techlife.cookpad.com

具体的にはどんなふうになっていくかというと、上の方からSectionっていう画面の単位を分けていってそれを横側に書いていくと、画面全体の構築も割と見やすい状態になっているし、一つ一つのレイアウトのファイルの単位も左側のlayout XMLが構築されていたら右側でそれに対して名前を入れたりとか画像を設定したりとか、というふうに設定していくと結構わかりやすいシンプルなレイアウトを組めたんじゃないかなと思っています。

ただ、この辺は開発上の問題が結構あって、たとえば動的なUIの更新というのが地味に難しかったりするんですよね。たとえば、要素を追加したりとか削除したりとかチップみたいなUI要素を入れたい時とかにViewを追加削除するのは結構力技になりがちで難しかったり、角丸とかボーダーとかの表現がAndroidだと地味に面倒くさいところがあってすごく難しいところでした。

あとは複雑なUI状態というのがLayout XMLのPreviewって一つしかないので、実際にデータを当てはめて確認するしか道がないので結構難しいというのがあります。クックパッドマートだと特に商品の受け取りの状態って複数あって、6・7種類ぐらいあるんですけどステージング環境だとその状態を再現するのにも一苦労するので、その動作確認がすごく大変だったっていうのがあります。あとは全体をコードで組み立てているので画面全体の構築っていうところが確認できないのが結構難しいところでした。

たとえばUIを追加とか、削除とかするみたいな話なんですけど、jQueryとかを見てもらえると分かると思うんですけど、特定のdivタグに子要素のビューを全部消してもう一回入れ直すみたいなことを力技で入れていた気がして、実行してみないとうまくできたかわからないとか、入れたところのビューの大きさだったり要素間のマージンだったりとかが結構調整しづらかったりとか、そういう問題がいっぱい起きました。あと角丸をつけたいだけでこれだけ書かなきゃいけなくて、これをbackgroundに設定するみたいなことをやらなきゃいけなくてすごく面倒くさかったっていうのがあります。

あとはパターン化を考えるときに、たとえばこのおためし価格っていうところは、当日適用されるパターンとされないパターンがあるんですけど、されないパターンをどういうふうに確認するのかというと、されないパターンを心の目で感じ取って実装するしかないというのが結構きついです。

AndroidとかiOSの開発って結構レイアウトが崩れることがあると思うんですけどこういうところが大きいんじゃないかなと僕は思ってます。

Jetpack ComposeはUI開発の何を変えたか?

2022年は結構Jetpack Composeの年になったかと思うんですけど、Jetpack ComposeってUI開発の何を変えたかっていうと、そういう動的なViewの追加削除だったりとか角丸だったりとかボーダーだったりとかそういう今までちょっとやりづらかったUIの表現だったりとかを簡略化してくれたのが結構大きいかなと思うんです。

他には高性能なPreviewを作ってくれたっていうのが僕の中では一番 Jetpack Composeの中でいいと思っていて、これが強力すぎてこれを扱うための開発スタイルの設計を去年はしていました。Jetpack Composeは直感的にif文を書いたりとかして条件式によってこのViewが表示されるされないみたいなものを制御できたりとか、あと角丸を表示するときに.clipって書くだけで一行で表現できたりとかこういうのが本当に助かりますね。

Previewのここがすごい

あと、今日はPreviewの話をすごくしたいんですけど、Previewはめちゃくちゃ良くて、PreviewのすごくいいところはPreviewを自分で書くことができるところです。逆に言うと自分で書かないといけないんですけど、任意の要素に対してPreviewを自分で書けるんです。なので、どんなPreviewでも自分で作ることができて、つまり同じUIに対しても複数のデータを入れてPreviewを書くことももちろんできるというのがかなり強いところかなと思います。あとはLive Editってホットリロードみたいなやつですね。Composeのコードを編集していると横で自動でコンパイルを走ってPreviewがどんどん更新されていってその状態が見えるという感じになってます。

Previewが出たばっかりのときはAndroid Studioが結構不安定でうまく動かなかったときもあったんですけど、最近はかなり安定して使えるようになってきたので、これなしではUI開発が進まないというぐらいに最高になってきました。

あとInteractive ModeだったりとかDeploy Previewっていうのがあって、作ったPreviewを実機とかエミュレーターにもインストールできるんです。たとえばボタンを押したときにトースト通知を表示してくださいとか、ボタンを押したときにこの要素を隠してくださいとかそういうイベントとかも全部出てきているので、実はPreviewだけでアプリも作れて軽いプロトタイピングみたいなところもできるというのが結構大きいなと個人的には思っています。

で、これをどういうふうに扱っていこうかというところを考えたときにLive Editにすごく注目していて、Live Editを有効に扱うためにはアプリのコンパイル時間を短くする必要があるんです。   なんでかと言うと、さっき言ったとおりLive Editするときは横でコンパイルを走って自動でPreviewが更新されるという話をしてたので、逆に言うとコンパイル時間が長かったら編集している際にコンパイルがすごく時間がかかって、Previewが表示されるまで30秒とか1分とかかかるっていうような状態になると結構きついと思うので、その時間を短くする必要があります。Composeの関数を書くためだけのモジュールを各フィーチャーごとに分離するような開発手法をクックパッド マートでは取り組みました。で、これでPreviewしたいモジュールだけビルドをすればよくなるので、かなりコンパイルも楽になったという感じです。

あとは表示パターンをPreviewで網羅できるようになったということで、ちゃんと網羅しようといういくつかの実装ルールだったりとかも決めたりしました。 実際にPreviewを書くと@Previewって付いているアノテーションが書いてある関数がPreview用のComposeの関数で、実際のComposeの関数に対して引き数にデータを渡してあげれば右側にデータが出てきます。上に書いてあるProviderが実際にこのPreviewに渡したいデータの一覧みたいな感じになっていて、これを増やしていくことでいろんな状態のPreviewをいっぱい作ることができたりします。

で、これ受取一覧画面の受取のデータの種類をバーッとPreviewで作ってみたいやつなんですけどこれすごく便利でアプリで実機で実際に確認しようとするとこれ全部再現するのめちゃくちゃ大変なんですけど、Previewで確認するだけでこんなに簡単に作れるっていうのはすごく良かったなっていう感じです。

あとはさっきのモジュールの分離の話をすると、モジュールはいっぱいわけています。で、Previewに必要なビルド範囲を最小限にしています。アプリのフルビルドって結構時間かかっていてクックパッドマートのアプリだと5分とか、最悪7分くらいかかったりするんですけど、フィーチャモジュールの一つだけのビルドとかだと数十秒程度で終わったりするので、これで開発速度が変わっているなという印象がありますね。

あとは開発方針の話でいくつかの実装ルールを分けた話を軽くしたと思うんですけど、画面全体のことをScreenというふうにクックパッドマートでは呼んでいって、区切り線で囲まれた部分をSectionというふうに呼んでいます。あとはSectionをいくつかの単位でまとめたのをContentというふうに呼んでいるんですけど、実装に落としていくとContentが並んでいるみたいな実装だったり、ファイルを分けてそのContentの中身にはラベルにあるテキストとSectionとみたいな感じでそれぞれ分けているというふうにして、しかもこれに対してそれぞれでPreviewも作れるので画面全体の構築もそうだし画面の細かいところに対してのUIのPreviewもかなり簡単に作れるようになったっていうのがすごく大きい変化だったなというふうに思います。

これからの話

ここまでが2022年ぐらいまで、僕らが今まで開発してきたUI開発の話だったんですけど2023年だからこれからの話でちょっとしようかなと思います。最近Relayっていうツールが気になっていて、これが何かと言うとfigmaのデータをComposeのコードに変換してくれるツールなんですね。で、この辺冷静に考えてみると結構面倒くさい作業で、デザイナーさんがfigmaでデザイン作ってくれて、我々アプリエンジニアがSwift UIだったりとかJetpack Composeでそれを実現するっていう開発フローになってるんですけど、これっていうのはfigmaとSwift UIとJetpack Composeで同じデザインで3回作っているっていうことになっていて、すごくだるくないかっていうふうに最近は考えています。逆に言うと、これをなくせればUIの開発って最高になるんじゃねって最近はちょっと考えたりしています。

コード変換だったりコード生成だったりを行ってくれるツールっていろいろあると思うんですけど、このRelayってツールは何がいいかっていうとAndroid Developersが公式で出してるツールなんですよね。だからちょっと興味があって、ちょっと見てるんですけどまだまだな段階といえそうな予感はしています。

ちょっと使ってみたんですけど、商品詳細画面のfigmaをComponentとしてRelayに喰わせるとComposeの関数を勝手に作ってくれます。今はレイアウトとか崩れちゃっているんですけど、フォントを調整して上手くいくととわりときれいに描画されたりとかして、結構簡単に作れてUI開発がわりとスムーズになるんじゃないかなというふうに妄想してるところです。

Relayを簡単に触れてみた所感としてはfigmaのリンクを貼るだけでコードが生成されるので簡単に使えるっていうのはすごくよくて、デザインのアップデートをfigmaにして、実装も右クリックで簡単にアップデートできるのでいいんじゃないかなと思ってるんですけど、Android Studio上からアップデートするしかなくてCIとかで更新するのがちょっと難しそうっていうところはひとつネックかなと思ってます。あとはfigmaのデザインがAuto Layoutっていう結構きれいにかかなきゃいけないという仕組みを使わないといけなくて、それがちょっと面倒くさいなっていうところがあります。

あと細かい制御が難しいっていうところがあるんですけど、割り切って使える場面はありそうかなと思っていて、共通Componentになるものだけ切り出すとか、そういうところはうまく使えるところがないかなって探しながら妄想しているところです。クックパッドマートでは細かく定義されてるものがあるので、小さなComponentぐらいだったらうまく使えないかなっていうのはちょっと妄想してたりします。

クックパッドマートのAndroidのUI開発についてたくさん話してきたんですけど、Jetpack Composeはいいぞと今まで皆さんも聞いてきたと思うんですけど、Jetpack Composeはいいんですよ。特にLive Previewが最高にいいので、皆さんもぜひ使ってみてください。あとは最後に言ってた通りデザインをコードに写し込む写経みたいな作業をいつか終わらせられたらいいなと思っているところです。

iOS画像非同期取得

こんにちは、モバイル基盤のヴァンサン(@vincentisambart)です。

半年くらい前に、iOSクックパッドアプリで画像非同期取得を自作することになりました。導入してから何ヶ月も問題なく動いているので、どう動いているのか紹介しようと思います。でもその前に自作することになった経緯を説明しましょう。

自作経緯

長年画像非同期取得に既存のライブラリを使っていましたが、昨年ライブラリの不具合で画像の取得が稀に失敗していたバグがいくつかありました。バグが修正されて、その数ヶ月後にまた似た問題。

この状態が好ましくなかったので、以下の選択肢のどれかにしようと議論しました。

  • 使っているライブラリのメンテナンスにもっと直接参加する
    • コードが古くメンテナンスしやすくなさそうでした。
  • 使っているライブラリのバージョンを固定する
    • 自動的に更新をやめても、バグ修正や最新のOSの対応のために定期的に更新した方が良いでしょう。
  • 別のライブラリにするか
    • 選定が難しいでしょう。例えばアプリをリリースしないとライブラリの安定性が判断しづらいです。
  • 自作するか

ライブラリを使っていたものの、複雑な機能は使っていませんでした。必要だったのは画像のダウンロード、キャッシュ、WebP対応、くらいです。

クックパッドでモバイルアプリの画像はWebPを使っているので、画像取得を自作することになっても、WebP読み込みにライブラリが必要だと思っていました。でも議論中に、iOS 14以降UIImageがWebPを読み込めるのが発覚しました。その時点で最新のiOSクックパッドアプリの最小サポートバージョンはすでにiOS 15でした。

画像ダウンロードとキャッシュだけが必要なら自作してみても良いかもという結論になりました。

実装

経緯の次は実装の詳細を説明しようと思います。その中で、まずは一番複雑そうなキャッシュの実装はどうしましょうか。

キャッシュ

多くの画像非同期取得ライブラリがキャッシュを2段階で行います:

  • 取得された画像ファイルをディスクにキャッシュします。アプリを再起動してもデータは残ります(ただし端末に空き容量が足りなくなった場合、OSが一部を消すことがあります)。
  • 画像ファイルが読み込まれたUIImageをメモリ上にキャッシュします。もちろんアプリを再起動したら一掃されます。ディスクからのファイル読み込みも、画像データのデコードも、メインスレッドでやらない方が良いことですが、UIImageをメモリ上でキャッシュするとどっちも必要ないのでこのキャッシュは直接メインスレッドで扱えます。

どちらのキャッシュの種類もできれば自分で実装したくありません。ディスクやメモリの空き容量、キャッシュの使っている容量、を気にする必要があるのはややこしそうです。でも実はFoundationにそのためのツールがあります。

ダウンロードされるデータをキャッシュするためにURLCacheがあります。ダウンロードに使うURLSessionconfigurationに代入するだけでダウンロードするデータがキャッシュされるようになります。

メモリ上でデータをキャッシュするためにNSCacheがあります。使い方がDictionaryに近いです。キーと値がAnyObjectであるべきなのでSwiftから使う場合少し不便な場面もありますが、今回キー(URL)をNSURLに簡単にキャストできるので、別のものにラップせずにAnyOjectにできます。

API

キャッシュのためのツールが揃ったので、画像取得APIを見てみましょう。

enum LoadingImage {
    case cached(UIImage)
    case inProgress(Task<UIImage, any Error>)
}

final class ImageLoader {
    func loadImage(from imageURL: URL) -> LoadingImage {

Taskが見られるのでSwift Concurrencyが使われているのですが、asyncメソッドではありません。

ビューの読み込みや表示時にOSから呼ばれるメソッド(collectionView(_:cellForItemAt:)viewDidLoad、など)は基本的にasyncではありません。

loadImageの定義がfunc loadImage(from imageURL: URL) async throws -> UIImageでしたら、asyncでないメソッドから呼ぶと新規タスクを作成する必要があります。

func viewDidLoad() {
    Task { @MainActor in
        // このコードが`viewDidLoad`のタイミングで実行されるのではなく、
        // `MainActor`が次回実行するようにキューされます。
        let image = await imageLoader.loadImage(from: imageURL)
        imageView.image = image
    }
}

loadImage内でawaitをせずにreturnしたとしても、同じMainActorで実行される別のタスク内なので、viewDidLoadの後に実行されてしまいます。画像がメモリ上のキャッシュにあったとしても、ビューの最初に描写で画像が表示されない可能性があります。最初の描写で画像なし、次の描写で画像あり、はチカチカして雑に見えます。フェードインを使えば少しマシですが、すぐ表示できればしたいです。

loadImage

本来のloadImageのコードを見てみましょう。

final class ImageLoader {
    func loadImage(from imageURL: URL) -> LoadingImage {
        // 画像がメモリ上キャッシュに入っていれば、すぐ返します。
        if let image = cachedImage(for: imageURL) {
            return .cached(image)
        }

        // バックグラウンドタスクで取得とデコードを行います。
        // `detached`を使うのはactorを引き継がないためです。
        return .inProgress(Task.detached {
            let request = URLRequest(url: imageURL)

            // コードの分かりやすさのために画像が`URLCache`に入っているのかどうか区別していませんが、
            // 必要であれば`urlCache.cachedResponse(for: request)`で入っているかどうか確認できます。

            let data = try await self.loadData(for: request)
            return try self.decode(data, for: imageURL)
        })
    }

ダウンロードをキャンセルしたければ、inProgress()に入ったタスクのcancel()メソッドを呼びます。Task.init()や今回のようにTask.detached()を使うとstructured concurrencyではないので、loadImage(from:)の戻り値を放置してもタスクがキャンセルされることはありません。

上記に使われているcachedImage(for:)NSCacheのメソッドを呼ぶだけです。URLAnyObjectではないので、NSCacheのキーに使うにはNSURLにキャストする必要があります。

final class ImageLoader {
    private let memoryCache = NSCache<NSURL, UIImage>()

    func cachedImage(for imageURL: URL) -> UIImage? {
        memoryCache.object(forKey: imageURL as NSURL)
    }

loadData(for:)に関しては、URLSession.data(for:)をもう少し使いやすくするだけです。

final class ImageLoader {
    private let session: URLSession
    private func loadData(for request: URLRequest) async throws -> Data {
        do {
            return try await session.data(for: request).0
        } catch {
            // タスクがキャンセルされた時、`session.data(for:)`がCocoaのエラーを発生されるのですが、
            // Swiftでは`CancellationError`がもっと自然だと思います。
            // タスクがキャンセルされている場合`CancellationError`を発生させる`try Task.checkCancellation()`がちょうど良いです。
            try Task.checkCancellation()
            throw error
        }
    }

decode(_:for:)UIImage(data:)をラップして、デコードされた画像をNSCacheに入れてくれるだけです。

final class ImageLoader {
    private func decode(_ data: Data, for imageURL: URL) throws -> UIImage {
        if let image = UIImage(data: data) {
            memoryCache.setObject(image, forKey: imageURL as NSURL)
            return image
        } else {
            throw InvalidImageDataError(url: imageURL)
        }
    }

ImageLoaderの全コードをこの記事の一番下にまとめました。上記になかったinitも含まれています。

使い方

上記に実装したAPIは基本的に以下のように使えば良いのではないでしょうか。

// 以前行われた取得が終わっていなければキャンセルします。
// キャンセルされる可能性ないなら、`loadTask`インスタンス変数は要らないでしょう。
if let loadTask {
    loadTask.cancel()
    self.loadTask = nil
}

// 取得を始めます。
switch imageLoader.loadImage(from: imageURL) {
case let .cached(image):
    // `image`をそのまま表示できます。

case let .inProgress(loadTask):
    // キャンセルできるために`loadTask`をとっておきます。
    self.loadTask = loadTask
    Task { @MainActor [weak self] in
        do {
            let image = try await loadTask.value
            // 無事に画像を取得できたので表示できます。
        } catch is CancellationError {
            // 待ち合わせていた`loadTask`がキャンセルされたので何もやるべきではありません。
        } catch {
            // 取得が失敗したので、placeholderを表示することが多いです。
        }
    }
}

移行

iOSクックパッドアプリで以前使っていたライブラリから自作ImageLoaderへの移行が割とスムーズでした。モジュール化によって画像読み込みが抽象化されていた場面多かったですし、ほとんどの画像が限られた数のビューに表示されています。

画像取得がまだ抽象化されていなかったら、まず抽象化するか、新しい実装の上に以前のに近いAPIを用意するか、の2択ですかね。前者は抽象化が終わったら移行するけど、後者は新しいコードに移行してから以前のAPIの利用を少しずつ減らします。

最後に

iOSの既存のツールを使えば、シンプルな画像取得は割とシュッと実装できました。だからといって画像取得を自作した方が良いわけでもありません。アプリによって状況が違います。自作したら自分でメンテナンスする必要がありますし、必要になった機能も自分で実装します。

自作するかどうか関係なく、画像取得が抽象化されていると、別のライブラリに移行しやすいので、いま直接ライブラリを使っていていて変える可能性があれば、とりあえず抽象化しても良いかもしれません。

この記事のコードを元に自作するのでしたら、機能の追加が必要かもしれません。ここで紹介されていませんが、iOSクックパッドアプリでは、画像をprefetchする仕組みや、メモリ上キャッシュになかった画像を表示にフェードインで表示させるFadeInImageView(UIKit版)とFadeInImage(SwiftUI版)があります。

// This project is licensed under the MIT No Attribution license.
// 
// Copyright (c) 2023 Cookpad Inc.
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import Foundation
import UIKit

public enum LoadingImage {
    case cached(UIImage)
    case inProgress(Task<UIImage, any Error>)
}

public struct InvalidImageDataError: Error {
    public var url: URL
    public init(url: URL) {
        self.url = url
    }
}

public final class ImageLoader {
    private let session: URLSession
    private let memoryCache = NSCache<NSURL, UIImage>()

    public init() {
        let configuration = URLSessionConfiguration.default
        let cacheDirectoryURL: URL?
        do {
            let systemCacheURL = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
            cacheDirectoryURL = systemCacheURL.appendingPathComponent("CookpadImageLoader", isDirectory: true)
        } catch {
            assertionFailure("Could not create cache path: \(error)")
            cacheDirectoryURL = nil
        }
        // デフォルトでは`URLCache.shared`が使われますが、もう少しディスク容量を使える画像専用のを使います。
        configuration.urlCache = URLCache(
            // `memoryCapacity`は試した限り0でも問題なく動きそうですが、一応念の為少しのメモリを割り当てます。
            memoryCapacity: URLCache.shared.memoryCapacity,
            diskCapacity: URLCache.shared.diskCapacity * 4,
            directory: cacheDirectoryURL
        )
        session = .init(configuration: configuration)
    }

    public func cachedImage(for imageURL: URL) -> UIImage? {
        memoryCache.object(forKey: imageURL as NSURL)
    }

    private func decode(_ data: Data, for imageURL: URL) throws -> UIImage {
        if let image = UIImage(data: data) {
            memoryCache.setObject(image, forKey: imageURL as NSURL)
            return image
        } else {
            throw InvalidImageDataError(url: imageURL)
        }
    }

    private func loadData(for request: URLRequest) async throws -> Data {
        do {
            return try await session.data(for: request).0
        } catch {
            // タスクがキャンセルされた時、`session.data(for:)`がCocoaのエラーを発生されるのですが、
            // Swiftでは`CancellationError`がもっと自然だと思います。
            // タスクがキャンセルされている場合`CancellationError`を発生させる`try Task.checkCancellation()`がちょうど良いです。
            try Task.checkCancellation()
            throw error
        }
    }

    public func loadImage(from imageURL: URL) -> LoadingImage {
        if let image = cachedImage(for: imageURL) {
            return .cached(image)
        }

        return .inProgress(Task.detached {
            let request = URLRequest(url: imageURL)

            // コードの分かりやすさのために画像が`URLCache`に入っているのかどうか区別していませんが、
            // 必要であれば`urlCache.cachedResponse(for: request)`で入っているかどうか確認できます。

            let data = try await self.loadData(for: request)
            return try self.decode(data, for: imageURL)
        })
    }
}

Project Googrename: Google Workspace で 14 年運用されたドメインエイリアスをプライマリドメインに変更 & 全ユーザーを安全にリネームする

コーポレートエンジニアリング部の id:sora_h です *1。今回は 3 ヵ月ほど前に実施した、Google Workspace テナントのプライマリドメイン変更について、記録を兼ねて説明します。

クックパッドは 2009 年頃 *2 より Google Workspace *3 を利用しています。当社の対外的なメールアドレスは cookpad.com ですが、Google ではプライマリドメインとして cookpad.jp が設定されています。各ユーザーには cookpad.com のアドレスを別名 (エイリアス) として登録されていて、メールアドレスとしては cookpad.com を利用、ただ Google へログインする時だけ cookpad.jp を利用する運用になっていました。想像が出来ると思いますが、これが様々な面で不便・混乱を発生させていました。どうしてこうなった… *4

この負債を解決すべく、2022/8 頃から緩やかに準備を始め、2023/3 上旬に全ユーザーのドメインとプライマリドメインを cookpad.com に変更しました。本稿では変更に踏み切った理由から下準備、当日~事後の作業について解説します。そこそこ長く様々なタスクがあったため乱雑な記事となっていますが、何かの役に立てば幸いです。

*1:技術部 SRE グループが主務です。一応…

*2:テナント自体は 2007 年から、業務に本格的に使われたのは 2009 年から、らしいですが詳細は不明です

*3:当時は Google Apps

*4:これも導入時期と同様に経緯は完全に損われていて不明、利用していないドメインで「一旦」設定してみて、そのまま本番利用されちゃったパターンと想像しています

続きを読む