ちょっと複雑なサイドバーをHotwireで簡単に作りたい

こんにちは、レシピ事業部プロダクト開発グループの渡邉(@taso0096)です。 クックパッドは最近、One Experienceというプロジェクトによって日本版とグローバル版のシステムが統合されました。 どちらのシステムもRailsで実装されているという点は同じですが、統合先となったグローバル版ではHotwireが使われていました*1。そのため、One Experience関連の開発ではHotwireが積極的に活用されています。本記事ではそんなHotwireの多くの機能が使われたデスクトップ版のサイドバーについてご紹介します。

デスクトップ版で表示されるサイドバー

ちょっと複雑なサイドバー

One Experienceに伴い、グローバル版にもともと存在したUIのまま移行するのではなく、いくつか画面構成の変更を入れる事になりました。特にデスクトップ版においては、自分のコンテンツにより素早くアクセスできるようにするためにサイドバーの導入が決まりました。

このサイドバーでは、一般的なナビゲーションメニューのほかに「きろく」と呼ばれるコンテンツを表示しています。「きろく」とはユーザの保存レシピや投稿レシピなどを整理するための機能です。また、「きろく」に保存したレシピはユーザーさんの手で、フォルダを作成して分類できます。このとき、レシピやフォルダの数が多いユーザーさんでも充分素早く表示させたいです。更に、サイドバーの外でレシピが保存されたとき、リロードせずともサイドバーの中の表示も追従する必要があります。以上を整理すると、作りたいものは以下のようなものということになります。

  • コンテンツが多く読み込みに時間がかかる場合を考慮して非同期で読み込む
  • フォルダが多い場合でも全てのフォルダを読み込める
  • レシピの保存やフォルダの作成などの操作時に表示を同期する

これらの要件はHotwireを使えば簡単に実装することができます。ここではHotwireの各機能を軽く説明しつつ、それぞれどういった実装をしたのかご紹介します。

Turbo Frames

Turbo Framesとはページ全体をリロードせずに部分的な更新を可能にするための機能です。部分更新したい箇所をTurbo Framesのタグで囲うことで、そのタグ内の部分更新が行われます。また、iframeのようにURLを指定することで全く別のページを埋め込むことも可能です。この場合はタグの中身が最初にレンダリングされ、その後に非同期で別のページが読み込まれます。

今回実装したサイドバーでは、非同期でコンテンツを読み込むためにTurbo Framesを利用しました。そのためにTurbo Framesで表示される専用ページを新規作成し、図の赤枠内で読み込むようにしました。この赤枠の部分ではローディングが最初に表示され、ページの読み込み後に非同期でコンテンツが読み込まれます。

Turbo Framesの範囲

多くの場合、Rails側で実装してある既存のページのロジックなどをそのまま流用して簡単に埋め込めるというのがTurbo Framesのメリットかと思います。一方、この例では新規ページをわざわざ作成しました。これはフォルダのページネーション実装をシンプルにするためで、Stimulus で実装しています。詳細は後ほど解説します。

なお、Turbo Framesはlazy loadingも可能であり、今回も設定しています。サイドバーはデスクトップ版ではスクロール位置などに関係なく常に表示されるものですが、スマホ版だとそうではないためです。

ページ遷移に伴うリセット

Turbo Framesによって非同期でのコンテンツの取得が実現できました。しかし、このままではページ遷移するたびにコンテンツの再取得が行われてしまいます。場合によってはそれでも問題ないですが、今回はページネーションによって読み込まれたフォルダの情報がリセットされることを避けたいと考えました。そうでなくともページ遷移する度にサイドバーがローディングによって一瞬使えなくなることはかなり不便だと思います。

そこでdata-turbo-permanent属性というものを使用しました*2。以下のようにこの属性が付与されたDOMはページ遷移時にもDOMが維持されます。

<div data-turbo-permanent>sidebar</div>

これにより一度読み込んだTurbo Framesのコンテンツは通常の画面遷移ではリセットされなくなります。これを再度読み込むにはブラウザのリロードやJavaScriptによる再読み込みの処理が必要になります。

レスポンス

Turbo Framesが取得するHTMLはページに埋め込まれる部分だけではありません。layoutテンプレートこそ使用されませんがActionViewでレンダリングされたページ全体がレスポンスとして返されます。そのため、ActionViewの中の一要素のみを埋め込みたいといった場合はそれ以外のレスポンスは破棄されてしまいます。おおよそのレスポンスは以下のようになっており、ブラウザ側のHotwireランタイムで解釈されて画面に埋め込まれます。

<html>
  <head></head>
  <body>
    <div>破棄される要素</div>
    <turbo-frame id="dom_id">埋め込みたい要素</turbo-frame>
  </body>
</html>

多少の無駄は生じてしまいますが、上でも書いたように既存のページをほぼそのまま流用可能であるというメリットの方が大きいと考えます。なお、今回は新規ページを作成しActionView全体が埋め込まれる形にしたため、そもそも破棄される要素は存在しません。

Turbo Streams

Turbo Streamsとはリアルタイムでのデータ更新を簡単にするための機能です。ページに対してDOMの追加・変更・削除などが可能であり、複数箇所を同時に更新することもできます。今回はレシピを新規で保存した際のレシピ数の更新や、フォルダ自体の編集を反映するために使用しました。

レスポンス

Turbo Framesと違い、Turbo Streamsではページ全体ではなく、差分のみをサーバーからレスポンスします。DOMをどのように扱うかについてはTurbo Streamsのアクションによって指示されます。例えばユーザが新規でレシピをフォルダに追加した場合を考えます。この場合は画面の赤枠部分が全て更新されます。

Turbo Streamsによって更新される要素

この時に返されるレスポンスはおおよそ以下のようになります。

<turbo-stream action="replace" target="dom_id">保存ボタンのHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">ドロップダウンのHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">「きろく」の「すべて」のHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">「きろく」の「保存済み」のHTML</turbo-stream>
<turbo-stream action="prepend" target="dom_id">「きろく」の「新規フォルダ」のHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">通知のHTML</turbo-stream>

このレスポンスをHotwireランタイムが解釈して画面の更新が行われます。

リダイレクト

サイドバーの同期を実装するにあたって、これまでは画面の更新が不要だったいくつかの既存のリクエストのformatをHTMLからTurbo Streamsに置き換える必要がありました。基本的に問題なく置き換えることが可能でしたが、リダイレクトが必要な場合は少し工夫する必要がありました。

例えばユーザがフォルダのページからそのフォルダを削除した場合を考えます。この場合はサイドバーの「きろく」から対象のフォルダのDOMを削除した上で「きろく」のトップにリダイレクトするという仕様になっています。これはデフォルトのアクションでは対応できないため、以下のようなリダイレクトのためのカスタムアクションを追加することで対応しました。

Turbo.StreamActions.redirect = function () {
  Turbo.visit(this.target)
}

View側では通常のアクションとほとんど同じように呼び出すことが可能です。

<%= turbo_stream.action :redirect, path %>

実際のレスポンスは以下のようになり、Hotwireランタイムで解釈されてリダイレクトが実行されます。

<turbo-stream action="redirect" target="path"></turbo-stream>

カスタムアクションは任意のJavaScriptを簡単に実行できるためかなり便利な機能です。しかし、だからと言ってデフォルトアクションで対処可能な機能に対してカスタムアクションを作成してしまうとコードの一貫性が失われてしまうため注意が必要です。

Stimulus

StimulusとはHTMLとJavaScriptを適切に切り離して書くための枠組みです。これによってHTMLだけを見た時にどんな挙動をするのかわかりやすくしたり、コード自体の再利用性を高めるメリットがあります。CSSがHTMLのclass属性を介して紐づいているように、StimulusではHTMLのデータ属性を介して任意のJavaScriptによる操作を可能にします。このJavaScriptはcontrollerという単位で分けられており、controllerのメソッドをDOMのライフサイクルやイベントをフックに実行するというのが主な機能です。これによって再利用しやすいJavaScriptになるような仕組みになっています。今回はページネーションのための無限スクロールとアクティブ状態の更新のために使われました。

無限スクロール

Turbo Framesのセクションでフォルダのページネーションについて解説しましたが、より使いやすくするために無限スクロールに対応します。グローバル版には元々無限スクロール用のcontrollerが実装されていたためこれをそのまま使用しました。実装としてはシンプルで、スクロールイベントを監視し閾値を超えたら次のページを非同期通信で読み込むというものです。Stimulusのtargetsとvaluesの機能を使ってリストの中身はどのDOMなのか、次のページのURLは何なのかといった情報を管理しています。

なお、Hotwireにおける無限スクロールの実装としてTurbo Framesの遅延読み込みを活用したものもあります。この場合は自分ではJavaScriptを一切書かずに無限スクロールの実装が可能です。ただし、単純なページネーションにcontrollerを登録だけすれば済むStimulusと比較すると、Viewに少し手を入れる必要がある点には注意が必要です。今回は便利に使える既存実装があったのでTurbo Framesによる無限スクロールをあえて採用するようなことはしませんでした。

アクティブ状態の更新

Turbo Framesのセクションで説明したように、サイドバーは画面遷移によるリセットを回避するためにdata-turbo-permanent属性が指定されています。しかし、これによって現在のページに応じて「きろく」のリンクのアクティブ状態を更新する機能が壊れてしまいました。これに対処するにはJavaScriptによってページ遷移を検出してアクティブ状態を更新する必要があります。作成したcontrollerの中身自体は単純なものですが、メソッドを呼び出すためのイベントの指定だけ少し特殊になっています。Stimulusではdata-action属性を使ってどのイベントにフックしてメソッドを実行するか指定することができます。このとき、基本的には属性が指定されたDOMに対するイベントを参照しますが、ページ遷移のようなグローバルのイベントフックしたい場合は@documentのようなsuffixを指定することで対応できます*3

<div data-action="turbo:visit@document->controller#method"></div>

まとめ

Hotwireを活用したちょっと複雑なサイドバーの実装についてご紹介しました。Hotwireの仕組みを利用することでインタラクティブなUIのためのJavaScriptをほとんど書かずに主要な機能の実装ができたかと思います。個人的には元々はNext.jsを書いていたこともありJavaScriptは好きですが、Railsを書く上でHotwireはかなり良くできた仕組みだと感じています。Hotwireはまだ使い始めたばかりの技術ですので、新しい知見が溜まったらまた共有したいと思います。

Simple Custom Compose Layout

こんにちは、「ウィリアム」です。クックパッドのAndroidエンジニアです。 私の日本語はまだ上手ではないので、これから英語で書きます!

Self-introduction (自己紹介)

Hi, my name is William, I'm an Android Engineer from the Cookpad's recipe team. I was originally in the global recipe team in Bristol, Uk, but I'm rehired to join the recipe team in Japan instead last year 2023. It is my first time ever in my life to write a techblog, please forgive me for the messy and unorganised structures, よろしくお願いします!

Statement

If you have been reading our techblog recently, you must be aware of what is currently going on with Cookpad! If you haven't, then I'll summarise it in a paragraph.

In the past, Cookpad recipe app operated separately in Japan, and rest of the world, Global. But now, we’ve merged into one, and we call it 'One experience.'

It was a long, challenging project, but also an exciting one! Looking back, what followed was the massive backlog of tasks created by the merger. There were so many things we wanted to accomplish, but we were often unable to due to time constraints, shifting priorities, or the limitations of the existing legacy architecture.

One day, if I still remember what it was all about, I’ll try to write about them. For now, I'll just write about something we face on a daily basis instead!

Recent Challenges We Faced in Android Development

Have you ever received a UI/UX requirement that isn't natively supported out of the box? One such requirement we received recently is:

Hide the ingredients section if there's not enough vertical space in the recipe card to prevent the cards in the list from expanding due to a long title or smaller screen sizes, which could result in an inconsistent UI.

Recipe Card

Above is an existing Compose component that we have currently in Your Collection tab screen (also known as きろく tab screen when you switch to JP region in the app). We have a different version of this component depending on the recipe type but ultimately they all have the same content:

Recipe card components

  1. Recipe title
  2. Recipe ingredient list
  3. Recipe author information
  4. Recipe actionable buttons
  5. Recipe image with「公開済み」label, this label appears when it is a published recipe belonging to the user.

The Problem

When there's not enough space

As you can see, the ingredient list section overlaps with the author information section when the recipe title spans two lines. This happens for a few reasons.

One of them is that the recipe image needs to be in a 3:4 aspect ratio; another is that the actionable buttons need to be positioned at the bottom of the card; and, additionally, the recipe height is now set to 160.dp.

So, there’s a Modifier.weight(1f) and a Modifier.height(160.dp) applied somewhere...

This will be more likely to happen when the user enlarges their device's default font size or screen size, which will cause the ingredient list (2) to overlap with other components if there isn’t enough space for it.

How to fix it

Now we need to make the ingredient list (2) disappear if there isn’t enough space for it, let's dissect the component!

Normal recipe card component

From the figure above, we identify the three components of the recipe card: the top, bottom, and optional center (ignoring the recipe image).

Logically, after the top and bottom are drawn, we need to calculate the remaining vertical space and then determine the required height to display the ingredient list.

After researching it for a while, it was actually easier than I originally thought. It's quite straight forward after going through Android documentation here developer.android.com

It's time for the classic hello world testing

I decided to try using the Layout Compose component to solve the problem above. Below is the composable preview of hello world test I used to experiment with the composable.

Layout(
    modifier = modifier,
    content = {
        Text("hello world")
    },
) { measurables, constraints ->
    val placables = measurables.map { it.measure(constraints) }
    val text = placables[0]

    layout(width = constraints.maxWidth, height = constraints.maxHeight) {
        text.placeRelative(0, 0)
    }
}

val placables is a list of components that need to be placed in the layout, determined by what we pass into the content parameter above. Another example will be:

Layout(
    modifier = modifier,
    content = {
        Text("hello world")
        Text("goodbye world")
    },
) { measurables, constraints ->
    val placables = measurables.map { it.measure(constraints) }
    val helloworld = placables[0]
    val goodbyeworld = placables[1]

    layout(width = constraints.maxWidth, height = constraints.maxHeight) {
        helloworld.placeRelative(0, 0)
        goodbyeworld.placeRelative(0, helloworld.height)
    }
}

Now, val placables is a list containing the Text("hello world") node and the Text("goodbye world") node.

When using .placeRelative(x, y) to position the node in the layout, the coordinates are relative to the current layout's (0, 0) point, which starts at the top-start corner on LTR devices and the top-end corner on RTL devices.

Alternatively, you can use .place(x, y) to position the node, but note that .place(x, y) ignores the RTL context, so it will always position the node relative to the top-left corner, regardless of whether the configuration is RTL or not.

Implementation

So, after familiarising ourselves with Layout, we can now start implementing the custom composable. Since we identified that there are three parts in the composable, we call them top, center, and bottom:

Layout(
    modifier = modifier,
    content = {
        Column {
            top() // The top part will consist of the recipe title.
        }

        Column {
            center() The center will contain the ingredient list.
        }

        Column {
            bottom() // The bottom part will include the author information and the actionable buttons.
        }
    },
) { measurables, constraints -> {
   ...
}

I wrapped them in Column as it's a column design and I can define the custom view composable parameter as ColumnScope.() -> Unit.

The next step is to place them in the layout

Layout(
    modifier = modifier,
    content = {...},
) { measurables, constraints -> {
    val placables = measurables.map { it.measure(constraints) }
    val topPlacable = placables[0]
    val centerPlacable = placables[1]
    val bottomPlacable = placables[2]

    layout(width = constraints.maxWidth, height = constraints.maxHeight) {
        topPlacable.place(0, 0)
        bottomPlacable.place(0, constraints.maxHeight - bottomPlacable.height)
    }
}

The code above will position the top component at the top-start and the bottom component at the bottom-start. Once the top and bottom components are in place, we will need to measure the available vertical space to determine if there is enough room to place the center component, which is the ingredient list.

Layout(
    modifier = modifier,
    content = {...},
) { measurables, constraints -> {
    val placables = measurables.map { it.measure(constraints) }
    val topPlacable = placables[0]
    val centerPlacable = placables[1]
    val bottomPlacable = placables[2]

    layout(width = constraints.maxWidth, height = constraints.maxHeight) {
        topPlacable.place(0, 0)
        bottomPlacable.place(0, constraints.maxHeight - bottomPlacable.height)

        val availableVerticalHeight = constraints.maxHeight - topPlacable.measuredHeight - bottomPlacable.measuredHeight
        if (centerPlacable.measuredHeight <= availableVerticalHeight) {
            centerPlacable.place(0, topPlacable.measuredHeight)
        }
    }
}

The steps to calculate the available height are as follows:

  1. First, we get the maximum height of the parent layout.
  2. Using the parent layout's height, we subtract the measured height of the top component and the measured height of the bottom component.
  3. With the remaining vertical height, we compare it to the center component's measured height and determine if there's enough space for it.
  4. If there is enough space for the center component, we place it below the top component. Otherwise, we do nothing.

With that in place, we can now use it in the recipe card composable and check if it works! The results are as follows as we tweak the device's font size and screen size:

Yay! It works, and now it's in production! It seems to be fine, as there haven't been any crash logs related to it, so that's good news... so far.

Below is the actual display of the custom view in my device after changing the default system font size from x1.0 to x1.3.

How it looks like in my Samsung S23 Ultra device with system font size enlarged by x1.3

If the top component or the bottom component takes up more vertical space than is available, they will overlap. However, this depends on the specific feature using the layout. In our current use case, this is acceptable, since the top component is simply a recipe title with a maximum of two lines, and the bottom component is just author information (with a maximum of one line) and actionable buttons with fixed sizes. Therefore, we don't need to handle this edge case at this time, according to the YAGNI (You Aren't Gonna Need It) principle.

The final code

@Composable
fun ResponsiveColumn(
    top: @Composable ColumnScope.() -> Unit,
    center: @Composable ColumnScope.() -> Unit,
    modifier: Modifier = Modifier,
    bottom: @Composable ColumnScope.() -> Unit
) {
    Layout(
        modifier = modifier,
        content = {
            Column {
                top()
            }

            Column {
                center()
            }

            Column {
                bottom()
            }
        },
    ) { measurables, constraints ->
        val placables = measurables.map { it.measure(constraints) }
        val topPlacable = placables[0]
        val centerPlacable = placables[1]
        val bottomPlacable = placables[2]

        layout(width = constraints.maxWidth, height = constraints.maxHeight) {
            topPlacable.placeRelative(0, 0)
            bottomPlacable.place(0, constraints.maxHeight - bottomPlacable.height)

            val availableVerticalHeight = constraints.maxHeight - topPlacable.measuredHeight - bottomPlacable.measuredHeight
            if (centerPlacable.measuredHeight <= availableVerticalHeight) {
                centerPlacable.placeRelative(0, topPlacable.measuredHeight)
            }
        }
    }
}

Closing statement

Building your custom Composable layout is actually quite straightforward and fun, although I deliberately left out some explanation in the post such as the layout's measurables and constraints.. please do read the Android documentation for their explanation! You can also check out in the code too if you prefers that.

There's also another use case for a similar technique inside Modifier.layout {} when implementing a horizontally-scrolling, full-width carousel in a lazy grid layout with content padding. This ensures that the carousel can scroll past the content padding. I’ll write about that in the future if I have the opportunity to do so.

If you're interested in the recipe I used in this post, feel free to take a look at the link provided below! Although it’s in English, it’s quite simple and straightforward. It took me a lot of trial and error to get the taste as close as possible to the one I had in Sweden when I was working there.

cookpad.com

* Clicking the link should open your Cookpad app if it's installed, it should work even regardless of which Play Store / App Store you installed it from ^^

I hope to improve my writing and provide more interesting use cases in the future. Thank you very much for reading!

モバイルアプリの One Experience

こんにちは。レシピ事業部でAndroidアプリ開発をしているこやまカニ大好きです。
好きなイジンカードは行基近松門左衛門、最近気になるカードは松尾芭蕉です。

このブログの日本とグローバルのクックパッドを統合しましたという記事で、日本とグローバルのクックパッドサービスの統合が行われたこと、プロジェクトの名称が One Experience であったことについて説明がありました。
もちろん One Experience プロジェクトについては Web だけでなくモバイルアプリについても行われており、現在日本向けにリリースされているクックパッドアプリは、クックパッドがサービスを展開しているすべての地域での表示に対応した One Experience版のアプリになっています。
この記事では、モバイルアプリの One Experience について、どのような作業が行われたのか大まかに概要を説明したいと思います。

概要

前提として、クックパッドでは以前から JPアプリと Globalアプリという2つのアプリがリリースされていました。
この2つのアプリは完全に別のアプリとして開発されていて、コードの共有率はほぼ 0% 、認証やAPIなどバックエンドの構成もまったく異なるものでした。
今回行ったモバイルアプリの One Experience とは、一言でいうと Globalアプリのコードで JPアプリを上書きしてアップデートする作業になります。
GlobalアプリとJPアプリはどちらも既存ユーザーがたくさんいるため、現段階では2つのアプリを1つに統合することは行わずに、1つのコード、リポジトリから2つのアプリをリリースしていくことにしています。

ひとつのリポジトリから複数のアプリを配信するというのは Kindle ストア版アプリの配信やAndroidTVアプリの配信などでよくある構成ですが、それによって別のアプリを完全に上書きするというのはかなり珍しいと思います。
こやまカニ大好きのAndroidアプリ開発歴はそこそこ長いのですが、この作業を行ったのは初めてでした。

この記事では、アプリを別のコードベースで上書きする際にどういった考慮が必要だったかを大まかに説明していこうと思います。
One Experience によるモバイルアプリの機能面の変更や細かい技術上の工夫などは後続の別記事で説明されていく予定なので、この記事ではJPアプリの上書きに必要だった作業の概要について説明していきます。
また、この記事では主に Android の用語で説明していきますが、 iOS アプリについてもだいたい同じような雰囲気だと思ってください。

モバイルアプリ固有の特性

詳しい作業内容に入る前に、モバイルアプリのリリースに関する特性について説明したいと思います。
すでにモバイルアプリの開発者はよく知っていることですが、この特性が One Experience のリリースを複雑なものにしているため、あらためて説明します。

ロールバックが難しい(実質できない)

モバイルアプリは、常に以前のものよりも大きい version code を持つアプリでしか上書きできません。
この特性により、一度 One Experience版で上書きされたアプリをJP版にロールバックするためには、JP版のバージョンを One Experience版よりも大きい値に変更した上で再度上書きする必要があります。
この方法でロールバックを行うと2つのリポジトリ間でバージョンを細かく管理する必要があるため、リリースフローがとても複雑になります。

さらに、JP版へのロールバックを行った場合、JP版 -> One Experience版 とアップデートしたユーザーだけでなく、 One Experience版を新規にインストールしたユーザーもJP版で上書きされてしまいます。
JP版 -> One Experience版 へのアップデート時に認証情報や一部のローカルデータをマイグレーションすることは決めていましたが、逆方向のアップデートやJP版 -> One Experience版 を2回繰り返した場合のサポートはあまりにも大変すぎるため、リリースに関する制約として One Experience版からのロールバックは行わないと決めました。
これにより考慮事項がかなり減り、 One Experience に集中して進めていく意思表示にもなったので、この意思決定ができたことは良かったと思います。

アプリがユーザーの手に届くまでに時間がかかる(bugfix や挙動変更が瞬時に適用できない)

モバイルアプリでは、アプリをサブミットしたあともリリースされるまでに審査があり、さらに公開したあともユーザーの端末にインストールされるまでは時間がかかります。
これは bugfix などのリリースでも同様で、不具合を修正してもユーザーの手元の不具合が発生していたバージョンを上書きするためには数日掛かる場合もあります。

特に One Experience リリースの初期段階では様々な不具合が予想されたため、プラットフォームの段階的な公開機能を利用し、様子を見ながら少しずつ公開率を上げていくことにしました。 One Experience では、初回のリリースから100% リリースまでおよそ3週間掛かっています。 ロールバックが難しい&更新に時間がかかるという状況で後述の認証情報のマイグレーションが失敗すると何もかもおしまいになってしまうので、初回のリリースからマイグレーションに成功したユーザーが観測されるまではかなりドキドキしていました。

JP版アプリをリリースするために行った作業

ここでは One Experience リリース時に実装が必要だった項目について簡単に列挙します。
ここにあげた項目以外もたくさんの修正が入っていますが、特に重要なものについて記述しています。

日本リージョンへの対応

Globalアプリはもともと多言語対応していたので、アプリ内に ja-JP リージョン設定を追加し、翻訳リソースを追加すれば日本語対応できる状態でした。
文字列の翻訳や画像以外でもヘルプページのURLやごく一部の実装はリージョンごとに処理を切り替えていて、 One Experience では ja-JP リージョン特有の処理もいくつか追加しています。
ja-JP リージョンだけの分岐が将来的になくなるのか、そういう仕様のままでずっといくのかはまだ決まっていない箇所もあり、今後はこういったリージョン固有の実装箇所の保守性も高めていけると良いなと考えています。

JPアプリビルド設定 の追加

Globalアプリ(Android)プロジェクトにはもともと Flavor によってビルドするアプリの ApplicationId や versionName などを切り替える機能が実装されていました。
JPアプリではこういった切り替えはすべてモジュールを切り替えることで行っていたので、 Flavor で切り替えることに少し抵抗があったのですが、元々の実装をベースにリファクタリングを加えることで Flavor ベースでの切り替えによって実装することができました。

バージョニング

JP版アプリは数年前からリリース年・リリース週番号ベースで自動的にバージョンを採番していました。
対してGlobal 版は手動のセマンティックバージョニングで、 major、minor を繰り上げるタイミングについてはあまり明確になっていませんでした。
JP アプリのほうが version code が大きかったこと、週次リリースというフローはOne Experience後も変わらなかったことから、JP/Global両方のアプリでリリース年・リリース週番号ベースのバージョニングに合わせることにしました。

リリースフロー

もともとJP版アプリはかなり自動化された週次のリリースフローを採用していました。
Globalアプリも週次リリースでしたが、運用方法には大きな違いがありました。
一番大きな違いはコードフリーズやサブミットのタイミングが自動化されておらず、リリースマネージャーとなった人間が手動でタイミングを決めていたことです。
Global 版アプリでは機能や画面の更新に対して翻訳リソースの更新を待つ必要があり、持ち回りでリリースマネージャーになった人間の活動時間にも時差があったため、リリースマネージャーがコードフリーズのタイミングを調整できたほうが都合が良かったのです。
最初はJPのように自動化したほうが良いと考えていましたが、 Global 版をベースに開発していくうちに考えを改め、 Global版のリリースフローに合わせることにしました。
現在のリリースフローでは、コードフリーズ、サブミット、リリースなどの処理が Global と JP でほぼ完全に同期して行われています。

Firebase プロジェクトの切り替え

JP と Global はそれぞれ別々に開発・運用されているアプリだったため、 Firebase プロジェクトも完全に分離されている状態でした。
最初はビルドするアプリによって Firebase プロジェクトを切り替える方針も検討したのですが、以下の理由により、 Firebase プロジェクトを Global が使っているものに統一することにしました

  • 社内の push 通知送信サービスがマルチproject をサポートできるように改修が必要
  • コードベースが完全に切り替わるため、Crashlytics に送られるクラッシュ情報の傾向が大きく変わる
    • One Experience 以降のクラッシュ情報だけ管理できれば良い
  • アプリ内の Firebase Analytics ログが完全に切り替わるため、 Firebase Analytics のログ内容が大きく変わる
    • One Experience前後の比較がしたいので連続性はあったほうが良いが、もともと重要なログは FirebaseAnalytics ではなく自前のログで比較する文化だったので、致命的ではない
  • Firebase Dynamic Links を複数のプロジェクトから生成したくない

アプリのマイグレーションについて

JP版アプリから One Experience版アプリにアップデートしたとき、きちんとマイグレーション処理を実装していなければアプリからログアウトし、すべてのローカルデータにアクセスできなくなります。
One Experience版アプリでは、JP版アプリを利用していたユーザーがそのまま利用できるように、認証情報や一部のローカル保存情報にアクセスできるように特別な実装を入れています。

特に認証情報のマイグレーションに関してはこれだけのために Globalアプリに AccountManager の実装を入れていたり色々な仕組みが入っているのですが、説明するとこれだけで一つの記事になってしまうのでまた別の機会に書くことにします。

まとめ

One Experience版アプリをリリースするための取り組みについて説明しました。
機能面、コード面ではこの記事で紹介した以外でも様々な変更がありますが、ここではプロジェクト全体に関わるような大まかなものに絞って紹介させていただきました。
これからもモバイルアプリの One Experience に関する記事は公開予定なので、今後の更新にもご期待ください。