既存AndroidアプリでのGraphQL/Apollo Kotlinの部分的導入

こんにちは、カンカクでAndroidエンジニアをやっている @haru067です。最近好きな寿司は、鯖です。

前回の記事では、COFFEE Appでの新規機能の開発にあたり、Jetpack Composeを活用した話をしました。 ここでは特に言及しなかったのですが、Jetpack Composeの他にもう一つ技術的な試みがありました。それがGraphQLの導入です。

そこで今回は既存のAndroidアプリに対して、新規機能の開発でGraphQLを導入した話をしたいと思います。

GraphQLとは

GraphQLは、Web API向けのクエリ言語(及びそのランタイム)です。 よく対比されるのはREST APIですが、GraphQLには、

  • スキーマに基づいてサーバー/クライアント間で一貫した型が利用でき
  • 複数のリクエストを送ることなく一度のリクエストで
  • 必要なデータのみを過不足無く

取得できる、といったメリットがあります。

AndroidにおけるGraphQLのクライアントライブラリで有名なものは、 Apollo Kotlin(以降、Apollo)でしょうか。2021年末にはv3.0.0がリリースされ、Kotlin Nativeのサポートやキャッシュの改善など、様々な機能が追加されました。COFFEE AppでもApolloを使用しており、この記事でもApolloの利用を前提として話を進めます。

導入の経緯

我々の場合では、どちらかと言えばサーバー側の嬉しさが先にあって、クライアント側のエンジニアでも検証した結果、良さそうだね、という流れでした。クライアント側の都合に合わせたAPIを設計する際には、サーバー側のエンジニアもその都合を理解する手間が発生しがちですが、GraphQLでは必要なものを予め用意しておけば、あとはクライアント側のエンジニアで自由に取得してね、という形にできるため、サーバー側のエンジニアの負担が減ると期待していました。

例えば、COFFEE Appでは店舗の情報を表すデータとして、ShopShopSummaryOrderShop、といった似通った型が複数存在し、混乱の元になっていました。(これはアプリの画面毎に必要な情報が微妙に異なり、それぞれで不要な計算を避けるためです。)こういった場合にGraphQLでは必要なものだけを取得するため、その辺りがシンプルに表現できて嬉しいはず、という期待がありました。

タイミングとしてもちょうど良かった

既存アプリにGraphQLを導入するにあたって、REST APIで取得する箇所とGraphQLで取得する箇所が混在するのはなるべく避けたく、そう考えたときに、(機能的に独立性のある)新規開発での導入は、「移行」ではなく「追加」によって実装できるため、タイミングとしては最適でした。

導入方針

ここからはAndroidの話です。 Apolloではクエリを書くと、それに基づくデータクラスが自動生成されます。

例えば次のようなクエリがあったとき、

query LoungeReservation($id: Int!) {
  reservation(id: $id) {
    id
    shop {
      name
      brand {
        name
      }
    }
  }
}

次のようなデータクラスが生成されます。

public data class Reservation(
  public val id: Int,
  public val shop: Shop,
)

public data class Shop(
  public val name: String,
  public val brand: Brand,
)

public data class Brand(
  public val name: String,
)

GraphQLではクライアント側で必要なデータを自由に取得できるため、UI(画面)に合わせてクエリを書き、Apolloが生成したデータクラスをそのままUIステートとして扱うことが多いように思います。これにはレスポンスとUIステートのマッピングを行う手間が削減できるというメリットがあります。

しかし、今回の実装ではUIステートを別途定義し、Apolloが生成するデータクラスを一度変換した上で利用することにしました。

// こうではなく
@Composable
ReservationText(reservation: Reservation) {
  Text("予約店舗: ${reservation.shop.brand.name} ${reservation.shop.name}")
}

// こうする
@Composable
ReservationText(uiState: ReservationUiState) {
  Text("予約店舗: ${uiState.brandName} ${uiState.shopName}")
}

data class ReservationUiState(
  val brandName: String,
  val shopName: String,
) {
  companion object {
    fun from(reservation: Reservation): ReservationUiState 
      return ReservationUiState(
        brandName = reservation.shop.brand.name,
        shopName = reservation.shop.name,
      )
    }
}

こうした理由は、大きく3つありました。

柔軟性:GraphQLとREST APIをうまく共存させたい

今回は新規機能の開発であり、データ取得の大半はGraphQLで完結すると想定していました。しかし、GraphQLとREST API、それぞれのデータソースから取得した値を結合したい、といったユースケースが出たらどうだろうか、今は無くても今後出てくる可能性があるかもしれない、と考えたときに、全体方針として抽象化を1段挟んでおいたほうが安全だろう、としました。

拡張性:複雑なUIステートも書けるようにしておきたい

(新)Guide to app architectureでも言及されていますが、UIステートの値から算出される値をUIステート内に定義する、というパターンがあります。

例えば、COFFEE Appではラウンジの予約画面で、入力項目を埋めた場合に確認のUIを表示したい、というケースがありました。

コードで表すと次のような感じです。

 data class ReservationUiState(
  val selectedCheckinId: String?,
  val selectedSeatId: String?,
   ...
) {
  val isConfirmSheetVisible: Boolean
    get() = selectedCheckinId != null && selectedSeatId != null

  ...
}

こうした処理は、Apolloが生成するデータクラスを拡張することでも実現できますが、そこまでするなら別途UIステートを定義してしまったほうが良いよね、という結論に至りました。

依存性:UI層がData層に依存してほしくない

前に述べたように、実装量の面ではApolloが生成するクラスをそのまま利用したほうが良いですし、GraphQLらしい作りと言えるでしょう。しかしそれはApolloへの依存をUIが持つことに他なりません。1からアプリを作る場合には、依存を許容することが多いと思いますが、今回は既存アプリへの導入であり、REST APIと共存する部分的な活用でした。それゆえGraphQLはあくまでREST APIと並んだ存在であり、UIから見たときには、それらが隠蔽されている、という方針を重視しました。実装の手間と依存、どちらを取るかという話で、これは最後まで迷ったところでした。

実際、使ってみて

想定と現実

まず、「必要なものを予め用意しておけば、あとはクライアント側のエンジニアで自由に取得してね、という形にできる」という想定には、完璧なスキーマが定義されている、という前提があるように思いました。 例えば、「リストの0件はnullで表現されるのか、空のリストで返ってくるのか」「ここは何故nullableになっているのか。実装上仕方なくなのか、手違いなのか」といった確認の往復は、いざクライアント側のエンジニアが実装に入ったタイミングで実際にありました。スキーマが成熟していけば徐々には減るとは思うのですが、コストとしては確実に存在していて、APIをレビューするのか、スキーマをレビューするのか、という差の話だと思いました。

あとはよくある話として、「クライアント側のエンジニアで値を自由に取得できる」は負荷観点でヤバいクエリが叩けるのでは?というのがあります。これについてはPersisted Queryという仕組みがあり、登録したクエリを叩くことで、無秩序なクエリが実行されるのを防ぐことができます。今回の実装ではPersisted Queryを使用しなかったのですが、代わりにある程度実装が固まったところで、関係者を集めてクエリをレビューする時間をとりました。これはバックエンド-フロントエンド間の認識を合わせる意味もありましたが、iOS-Android間で妙に異なるクエリがないか確認することで、実際片方のOSで実装漏れが見つかる、といった副次的な効果もありました。

Apollo Kotlinについて

わりとKotlin/Androidフレンドリーというか、Androidで利用することを想定してしっかり作られている印象を受けました。まずコルーチンに対応しています。大事ですね。 次に(一部限定的ではありますが)マルチプラットフォームにも対応しています。これは人によってはありがたいかもしれません。 あとはキャッシュ周りが良い感じです。SQLiteのキャッシュをSingle Source of Truthとする ことができて、キャッシュ→ネットワークとFlowで複数回データをemitするようなAPI も最近追加されました。これはイマドキなアーキテクチャを作っていく上でありがたいと思います。また、(効率のよい)キャッシュの保存もID指定を少し足してあげる程度 で実装できます。気になる点としては、SQLiteのキャッシュが蓄積される問題があって、この辺りが解消されるといいな、という期待があります。

以上、レポっす

というわけで、GraphQLのお話でした。アプリが実際に動いている様子を見たい方は、COFFEE Appをダウンロードしてみて下さい!

あとは、最後にお決まりのやつを貼っておきます。よろしくお願いします。

カンカク入社から1年間のフロントエンド開発を振り返ってみた

こんにちは。フロントエンドエンジニアのKeita(@KeitaBangkok)です。

2021年4月下旬にカンカクに入社してから、もうすぐ1年が経ちます。

カンカク1人目のフロントエンド専任者として、複数の自社プロダクトを横断しつつもコア開発を担当する裁量大きめな1年になりました。入社から22年4月現在まで、弊社のフロントエンドエンジニアは自分1人となっています。

この1年間で自分が各プロジェクトでやってきたこと、そして今後やっていきたいことを中心に振り返ってみます。

担当プロジェクト一覧

まず、自分が各プロジェクトに取り組む上での共通のマインドのようなものをまとめてみます。

弊社のバックエンドエンジニアは、一通りのフロント実装をこなせるフルスタックな方が多く、特に入社当時はフロントエンド専任の自分がどのように価値を出していくかを考えていました。言語化すると、特に以下3点を意識して開発しています。

  • 内部コードの質にこだわる
    divタグの踊り食いで見た目の実現はできても、意味合いに適した正しい文書構造をつくることが「マークアップ」と言える行為だと思っています。
    例として、意味合いに適した定義リスト(dlタグ)を使うよりも、divタグを使用した方がCSSの記述量が少なく済む場合があったりします。ただ、ここは逃げずになるべく文書構造にこだわったマークアップをするべきだと考えています。

  • 各主要ブラウザで正常な表示/挙動を担保する
    弊社ではInternet Explorer とOperaを除く主要ブラウザに対応するルールを設けています。こうなると一番の曲者はSafariですね。
    簡単な例を出すと、CSSプロパティscroll-behaviorがSafariの大半のバージョンで対応していないためJavaScriptでの処理が必要になったりと、Google Chromeで確認しているだけでは気づけない点にも気を配る必要があります。全ブラウザで表示/挙動が統一された平和な世界が訪れるにはまだ時間がかかりそうな印象です。

  • デザインデータからは読み取れない箇所のUI/UX実装をリード
    マウスオン時のスタイルやアニメーションを用いた各要素の表示については、主体的にデザイナーと相談し実装するようにしています。例えば実装案A~Cをこちらで用意した際、デザイナーさんのロジックに基づいて選ばれたものが自分の中の第一候補でなかったりする場合も多く、気づきがあって面白いです。

続いて、各プロジェクトについて見ていきます。

GOOD EAT CLUB

食のマーケット&ファンクラブ『GOOD EAT CLUB』のフロントエンド改修を担当しています。直近では、自分が特に工数を使っているサービスでもあります。

バックエンドエンジニアの方が裏側の機能開発と合わせてフロント実装を一任することもありますが、現状自分の担当となる範囲は主に以下2点です。

  • UI/UX改善施策の実装
  • キャンペーンサイトのリリースマネージャー

現状GOOD EAT CLUBの開発環境はフロントエンドとバックエンドが共通のリポジトリで管理されており、画面表示部分はRuby on Railsのerbおよびhamlファイル、そして一部のVue.jsコンポーネントで描画されています。

今後として、フロントエンドをバックエンドから分離するタスクが実施予定になっています。TypeScriptも導入するためVue.jsよりも相性の良いReact(Next.jsにする予定)でフロントの実装をし、バックエンドとはGraphQLで連携する想定です。

これは技術的にも工数的にもチャレンジングなタスクになることが目に見えており、完遂するためにはいよいよ自分以外のフロントエンドエンジニアの協力が必要だなという所感です。

TAILORED CAFE online store

スペシャルティコーヒーを販売するECサイト TAILORED CAFE online storeの開発も担当中です。 現在もリニューアルが進行中で、自分が携わっているプロジェクトの中でも特に新試作起案→実装までの回転率が高いです。

また、開発に着手する前の段階で、コンセプト決定などの根本的な部分のディスカッションに参加する機会が多い環境でもあります。

フロント実装としては、 TOPページや定期便購入ページのリニューアル、コーヒー診断機能の改修、クリエイターとのコラボ商品販売ページの作成などを行ってきました。自分は本ECサイトがShopify開発デビューとなったため、Liquidファイルを使用したサイト実装についてキャッチアップしつつの開発作業でした。

今後もCVR改善のための施策を行っていきますが、フロントの技術的な面としては、現在静的実装になっている部分をLiquidファイルの特性を生かし、コード改修なく管理画面上からのサイト更新ができる範囲を拡張していく予定です。

WAKE

冷凍スムージーのサブスクD2Cブランド『WAKE』の立ち上げと運用も担当していました(現在はサービスサイト移行のため休止中)。

f:id:keitainoino:20220405145318p:plain
【WAKE】TOPページ

定期便商品を販売する上でメリットが大きいと判断し、ecforceというECプラットフォームを利用していました。

ecforce自体が比較的新しいサービスのためサイト実装上の諸情報が世に出回りきっていないこともあり、サイト公開前数日はカスタマーサポートに電話しながらカタカタとコードを打っていたのを覚えています。

サイトはLiquidファイルメインで構築されており、Shopify開発で得た知見はある程度生かすことができたように思います。数字的な結果を受け、短いスパンで新規施策を形にしていくことが多く、実装にはスピード感が求められました。

当初は定期便商品のみを販売していましたが、ニーズを踏まえギフト商品の展開も行っていました。

f:id:keitainoino:20220407003748p:plain
【WAKE】GIFTページ

HOLON

クラフトジンのブランド『HOLON』を販売するECサイトです。

自分の入社前からサイトの運用が始まっており、自分の担当は新商品販売時の調整や新規セクションの追加が主になっています。

HOLONはInstagramの運用が成功していることもあり、今後試したい施策として、サイトにもInstagramコンテンツを表示することで滞在時間ならびにCVR向上を実現できるか検証したいと思っています。

また、根本のサイト構造はリリースから大きく変わっていない状態での運用が続いているため、各数値やヒートマップ観測などをもとに、掲載コンテンツの最適化も実施したい気持ちです。

自社コーポレイトサイト

弊社のコーポレイトサイト改修もタスクの一つです。

入社前の時点でSEOやUX観点の改善案レポートを自主的に提出するという僭越なアクションをしており、入社後は列挙していた改修を第一タスクとした取り組んだのを覚えています。

コーポレイトサイト改修で一番大きかったタスクとしては、採用強化のためのコンテンツ拡充とNext.js+HeadlessCMS導入になります。詳細は以下記事をご覧ください。 developers.kankak.com

現在CMS化しているのはNewsおよびCareerセクションのみであるため、他セクションについてもCMS化を進めていく予定です。

TAILORED CAFE ブランドサイト

自社運営のカフェ、『TAILORED CAFE』のブランドサイト改修も担当しています。

入社以後、大きめなタスクとしてまずデザインのフルリニューアルがありました。リニューアル前の以下サイトキャプチャと現行サイトを比較してみてください。

f:id:keitainoino:20220404195929p:plain
【旧】tailoredcafe.jp

フルリニューアルにあたりデザイナーさんが作成したデータが以下です。弊社ではデザインデータ作成にFigmaが採用されています。

f:id:keitainoino:20220408133658p:plain
【Figma】tailoredcafe.jp

自分が直近で意識的にも特に重きを置いてインプット/アウトプットを行っているのはReact関連のものになりますが、上記のようなデザインカンプからのコーディングタスクも定期的に発生するため、根底にあるWeb制作面のスキルも衰えることがないと思っています。

本サイトはリニューアル以前からwebpack構築の静的な運用が続いていましたが、ご時世的に各店舗の営業時間の変更や店舗メッセージの更新頻度が高く、こちらもコーポレイトサイト同様Next.jsおよびHeadlessCMSを導入し、サイト更新の簡易化を図りました。

後にONLINE STOREの商品掲載箇所もCMS化しており、引き続きCMS導入セクションを拡充していく予定です。

KITASANDO COFFEE ブランドサイト

同じく弊社が運営しているカフェ『KITASANDO COFFEE』のブランドサイトです。

自分の入社前まで、このサイトはノーコードサービス"STUDIO"で構築されていました。 エンジニア知識を必要とせず、簡易的にサイトを立ち上げることができるSTUDIOはメリットが多いですが、細かく見ていくと以下3点の改善ポイントがあるように思えました。

  • SEO観点でベターなHTMLタグを配置
  • HTML階層を効率化
  • デザイン改修のハードルを下げる

見た目が全く同じサイトをスクラッチで構築し、上記3点を解消しました。

フロントエンドエンジニアが在籍していて、ある程度工数も取れると言うことであれば、スクラッチで構築しておいた方が良いと個人的には思っています。STUDIOの都合上、やや冗長的に思えたHTMLの階層構造も解消し、内部コード量もスッキリとさせることができました。

結果的に、後にデザイン込みのコンテンツ拡充を行う機会も出てきたため、一から構築し直して正解だったなと思います。

現在は同ブランドでラウンジ業態店舗を下北沢にオープンしているため、この新店舗を訴求する新コンテンツを追加していく予定です。また、このサイトにもHeadlessCMSを導入し、店舗運営者でもサイト更新が可能な形に改良していきます。

おわりに

これまでとこれからのフロントエンド開発について語ってみました。

開発の内容に加えて、過去1年で様々なプロジェクトに携わることができた点も、自分が弊社でモチベーション高く業務に取り組めている要因の一つだと感じています。

2年目の計画としては、フロントエンドのモダンスキル導入や、バックエンドと連携する環境を新規構築していくことで、開発の質向上も重点的に行っていく予定です。

事業や開発内容に興味をお持ちいただけた方は、ぜひ以下リンクからお気軽にご連絡ください!

自社コーポレートサイトにNext.js+HeadlessCMSを導入してみた

こんにちは。カンカクでフロントエンドを担当しているKeita(@KeitaBangkok)です。

最近、自社のコーポレートサイトにNext.js(TypeScript)とHeadlessCMSを導入しました。

社内的に意義のあるアウトプットになりつつ、比較的モダンな技術を扱う良い機会となったので、簡潔に概要をまとめていこうと思います。

導入の経緯

社内の採用強化を目的としたプロジェクトの一つとして、コーポレートサイトのコンテンツ拡充の話が挙がりました。

元々静的構築なサイトでしたが、これにNEWSセクションとCAREERセクション(採用職種とリンクを掲載)を追加し、諸情報の露出を増やしていく狙いです。

このため元々はwebpack構築だったサイトに、エンジニア以外のコーポレート担当の方でも容易に更新できる仕組みとしてHeadlessCMSを導入、そして今後も考慮し思い切ってこのタイミングでReact(Next.js)にリプレイスしよう、という流れでした。

サイト外観の変化以上にリポジトリ内のディレクトリ構造がガラッと変わる改修タスクになりました。

ちなみに、弊社では自社運営カフェのブランディングサイトで既にNext+HeadlessCMSを導入済みでした。

HeadlessCMSの選定

HeadlessCMSにはContentfulを採用しています。

最終候補としてContentfulとmicroCMSが挙がり、無料枠の多さに分があるContentfulに決定しました。microCMSと比較するとContentfulは日本語非対応になっていますが、それぞれのコンテントモデル(CMS化する項目)のタイトル等はこちらで設定するため、コーポレート担当の方が触る部分は実質日本語にすることができ、操作に抵抗感のない管理画面になっているように思います。

f:id:kankak_inc:20220329182546p:plain
contentful操作画面

実際に担当の方に管理画面の操作方法を共有しましたが、特に滞りなく理解してもらえた印象です。

実装概要

簡潔に開発の概要をまとめていきます。
実装にあたりNext+TypeScriptな環境を構築しました。

ディレクトリ構造

tsxファイルを格納しているディレクトリ構造は以下の通りです。

⁝
├─ src
    ├─ pages
    ├─ section
    ├─ components
    └─ ...

この内、CMS関連のファイルが格納されているのはsrc/pagessrc/sectionです。

create next appで環境構築するとルーティング用のpagesフォルダがルートに配置されますが、個人的にはこの上で別途componentsフォルダ等を設けると雑多な構造になる印象があります。そのため、Next.jsがデフォルトでサポートしているsrc/pagesの構造を採択しました。

Contentful用の型定義ファイルやライブラリファイルもsrc配下にまとめ、ルートからの枝分かれを最小限にした構成としています。

CMS導入部分

以下はページ表示の大元となっているsrc/pages/index.tsx内メインコンテンツ部分の抜粋です。

<main>
  <Hero />
  <News content={news} />
  <Career content={career} />
  <Vision />
  <Brands />
  <About />
  <Contact />
</main>

実際のサイトを見ながらだとわかりやすいと思いますが、セクション毎にコンポーネント化しています。NewsとCareerタグでは下位コンポーネントにCMSのデータ受け渡しを行なっています。

例として、データを受け取ったNewsセクションsrc/section/News.tsxの中身は次の通りです。

import { EntryCollection } from 'contentful';
import { ICorpNewsFields } from '../@types/generated/contentful'; // Contentful用型定義ファイル

// Contentfulデータの型定義
interface NewsProps {
  content: EntryCollection<ICorpNewsFields>;
}

const News = (props: NewsProps) => { // 受け取ったContentfulデータの型指定
  return (
    <section className="news" id="news">
      <div className="section__inner">
        <div className="sectionTitle__border">
          <div className="sectionTitle__wrap">
            <h2 className="sectionTitle">NEWS</h2>
            <span className="sectionTitle__sub">プレスリリース</span>
          </div>
        </div>
        <ul className="newsList">

          {props.content &&
            props.content.items.map((value, i) => (
              // Contentfulデータ(Newsタイトル,URL,日付)をループ出力
              <li key={i}>
                <a href={value.fields.url} target="_blank" rel="noopener noreferrer">
                  <div className="newsList__text">{value.fields.title}</div>
                  <span>{value.fields.date}</span>
                </a>
              </li>
            ))}
        </ul>
        <div className="newsMore">
          <a href="https://prtimes.jp/main/html/searchrlp/company_id/47640" target="_blank" rel="noopener noreferrer">
            Learn more &gt;
          </a>
        </div>
      </div>
    </section>
  );
};

export default News;

value.fields.xxxにそれぞれContentfulで設定した値が入っています。Contentfulのライブラリを用い生成したICorpNewsFieldsにてデータの型安全を担保しました。

これを画面表示した結果が以下キャプチャです。

f:id:kankak_inc:20220329212310p:plain
CMS化したNewsセクション

導入後の所感

デザインにこだわった既存サイトにCMS機能を持たせたいニーズは結構あると思いますが、その際にはやはりHeadlessCMS導入が有効ですね。簡単なテキスト修正レベルのタスクのために都度コードをいじりpushを繰り返すことは地味に負荷がありますが、これを解消できるメリットも大きいです。

また、採用強化を主目的としたタスクでしたが、弊社で利用実績のなかったNext.jsやHeadlessCMSを技術環境に追加できたこと自体も、エンジニアの方のアトラクトにプラスに働くのではないかと思ったりもしています。

最後に

以上、最近のフロントエンドエンジニアとしてのアウトプットを語ってみました。

カンカクでは現在、全方位的にエンジニアを募集しています。
興味をお持ちいただけたら、まずはカジュアルにお話してみませんか?以下リンクから気軽にご応募ください!

「最適なiOS のGraphQLクライアントを求めて」という発表をしました

こんにちは。カンカクのiOSエンジニアの@redryeryeです。

3月24日に開催された、potatotips #77 iOS/Android開発Tips共有会「最適なiOS のGraphQLクライアントを求めて」という内容でライトニングトークを行いました。

内容

今年の始めに、カンカクではカフェ事業としては初となる、ラウンジ業態の店舗が下北沢でオープンしました。これに伴い、プレオーダーや事前決済ができるモバイルアプリ『COFFEE App』にラウンジの予約機能が追加されました。

COFFEE Appの従来の機能ではアプリとサーバ間の通信にREST APIを使用していますが、今回のラウンジの機能ではGraphQLを採用しています。
GraphQLクライアントには比較的メジャーなApollo iOSを採用しました。

この発表ではApollo iOSを使った所感や、導入検討をしているもう一つのGraphQLクライアントSwiftGraphQLについてApolloと比較しながら紹介しました。

詳しくは以下のスライドをご覧ください。

おわりに

今回はラウンジの機能をiOSで実装する中で直面した課題を紹介しました。Androidでも新規UIの全てをJetpack Composeで記述していたりと面白い挑戦をしているので、気になる方はこちらの記事もぜひご覧ください。

developers.kankak.com

採用案内

カンカクではこのような新たな技術を模索しながら楽しく開発していく仲間をさがしています。 採用案内などは、詳しくは以下のエントリーをご覧ください。
応募までは振り切れないという方でも雑談ベースでのお話大歓迎です!

Jetpack Composeをフル活用してデカめの新規機能を作った

こんにちは、カンカクでAndroidエンジニアをやっている @haru067です。好きな麺は平打ち麺です。

プレスリリースにもありますが、カンカクではカフェ事業としては初となる、ラウンジ業態の店舗を2022年1月20日に開店しました🎉 これに伴い、プレオーダーや事前決済ができるモバイルアプリ『COFFEE App』にも、ラウンジの機能が追加されたのですが、Androidでは新規UIの全てをJetpack Compose(以降Compose)で記述しました。

そこで今回は、Compose導入の舞台裏について、話したいと思います。

Jetpack Composeとは

宣言的にUIを記述する、Androidの新しいUIツールキットです。*1
2021年7月には、安定版である1.0.0がリリースされました。 developer.android.com

技術選定

まず、Compose導入の経緯についてです。多くのアプリがJavaからKotlinから移行していったように、これまでのAndroidのView(以降View)からComposeへの移行は、遅かれ早かれ発生するものだと我々は考えていました。従って、焦点になるのは、Composeを「どのタイミングで」「どのように」導入するかでした。ラウンジ開発でのCompose導入を踏み切るにあたり考慮したのは、大きく分けて、次の3つでした。

  • 開発人数
  • 開発期間
  • 画面要素

開発人数

複数人のAndroidエンジニアを抱える組織では、足並みや認識を揃えるところから出発したり、学習コストについても考える必要があります。今回はAndroidエンジニア1人(わたしです)での開発であったため、学習コストを深く考慮せず、プロトタイピングとある程度平行する形で、機能開発を進めることができました。また、ComposeのCodelab等で、ある程度開発のイメージを掴んでいたのも、導入の後押しになりました。

開発期間

カンカクでの開発は店舗の開店日など、物理的な制約によって開発の期限が決まることも多く、ラウンジ開発も例外ではありませんでした。期限が決まっている開発で技術的な挑戦をすることは、不確定要素を増やすという点でネガティブでしたが、導入の仕方を工夫することでリスクを下げれば、許容できるものと判断しました。これについては後ほど話します。

画面要素

Composeを使用する上で、最も避けたいのはViewの世界とComposeの世界を相互に行き来するような状態でした。既存の機能に手を加えるケースではこのような問題に直面しがちですが、大半が新規の画面追加であるラウンジ開発はそういった懸念が無く、Composeを導入するには絶好の機会でした。また、ラウンジは複雑なUI要素が存在しないものの、画面数がシンプルに多い機能開発でした。そういった点でもラウンジとComposeは相性が良かったです。Composeのメリットである記述の短さは画面数(≒UI要素の量)の多い開発において絶大な効果を発揮しますし、懸念していた「Composeに今回必要な機能やUIコンポーネントが無いかもしれない」といった点もまあ、これぐらいのUIならいけるだろう、とある程度の自信を持って判断することができました。

f:id:kankak_inc:20220124151348p:plain
Figma上でのラウンジの画面

移行戦略

「今回」やると決めたあとは、それを「どのように」導入するか、の話です。ポイントは、Compose導入という技術的な挑戦をしつつ、開発期限を厳守する、という点です。ここで考えられる最悪のシナリオは、Composeの採用を途中で断念し、全てをViewに書き戻す作業が発生することでした。それをなるべく避けるために我々が採用したのは、Composeを使用しつつも、画面は従来どおりFragmentで区切って構成していく、という方針でした。(Compose as a Viewとでも呼びましょうか)

こうではなく
Fragment ┬ ComposeScreenA
         ├ ComposeScreenB
         └ ComposeScreenC

こうする
FragmentA ─ ComposeScreenA
FragmentB ─ ComposeScreenB
FragmentC ─ ComposeScreenC

個々のFragmentがComposeViewを持つことで、Fragmentの境界がComposeの境界となり、Composeで実現できない処理が出てきたとしても、それは該当するFragmentの範囲内での修正で済むはず、という考えです。

そしてFragmentで区切るもう一つのメリットは、既存のView資産を再利用しやすい、という点にあります。Fragmentが存在することで、画面遷移は従来と変わらずに実現できますし、ローディング(ProgressBar)やエラー表示(Snackbar)といった、元々アプリ全体で共通化していた処理も、既存のコードを流用して、手っ取り早く実装することができました。

Composeをどう書くか

次に考えたのは、「何かあったとき、すぐにComposeからViewに戻せるような状態とは何か?」という問いでした。ComposeやViewが依存しうる要素、代表的にはFragmentやViewModelとの相互作用をどう設計するか?という話です。これに関しては次の2つのルールを定めました。

ある画面Hogeが存在し、それに対応するHogeFragmentがあったとする。このとき

  1. HogeFragmentに唯一のstatefulなcomposableである`Screen`を書き、それ以外にcomposableを定義しない
  2. それ以外のcomposableはstatelessで、FragmentやViewModelに一切依存してはいけない
     (Fragment/ViewModel agnosticである)

コードで表すと次のような感じです。

// HogeFramgent.kt

class HogeFragment : Fragment() {
  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View {
    val composeView = ComposeView(requireContext()).apply {
      setContent { Screen(viewModel) }
    }
    
    ...

    return composeView
  }

  ...

  @Composable
  private fun Screen(viewModel: HogeViewModel) {
    val uiState by viewModel.uiState.collectAsState()

    HogeScreen(
      uiState = uiState,
      onBackPressed = { popBackStack() },
      onClickA = viewModel::onClickA,
      onClickB = viewModel::onClickB,
      ...
    )
  }
}
// HogeScreen.kt

@Composable
fun HogeScreen(
  uiModel: HogeUiState,
  onBackPressed: () -> Unit,
  onClickA: () -> Unit,
  onClickB: () -> Unit,
) {
  ...
  SomeContents(...)
  ...
}

@Composable
private fun SomeContents(
  foo: String,
  bar: Int,
  event: () -> Unit,
) {
  ...
}

composable中でstateが必要になった場合、state hoistingにより、ルートである Screen が子のstateを持つようにし、子のcomposableに必要な値は全て引数として渡される、という方針です。こうすることで、FragmentやViewModelに依存する処理を1箇所にまとめることができますし、それ以外のcomposableをstatelessにすることができます。statelessであるということは、後述のプレビューやUIテストを記述する上で非常に役に立ちました。

Composeを使ってみて

ここからはComposeを使用してみての感想タイムです。まずは良かった点について。

開発効率

今回の開発で最も多かった画面パターンの一つが「スクロールが必要で、コンテンツはネットワークからの取得により動的に定まるが、ページング等は必要としない画面」でした。つまりRecyclerViewでは扱うには大げさで、ScrollViewで扱うには不十分な画面です。ComposeではLazyColumnやScroll modifierによって簡潔にスクロールするUIを記述できるので、大きく助けられました。「動的に定まる」の部分に関しても、コードで記述する、というComposeのスタイルが上手くマッチして、高速に開発をすることができました。

動作確認

previewが重いけど便利です。特にナビゲーション階層の深いところに存在する画面や、特定の状態でしか出現しないUIなど、再現に手間がかかるものほど、IDE上でのレイアウト確認に助けられました。次のスクリーンショットは、Spinnerの状態別のレイアウト確認の例です。

f:id:kankak_inc:20220124153029p:plain
IDE上でのSpinnerの状態別UIプレビュー

ラウンジ開発で特に助かった瞬間は利用履歴の画面でした。この画面は予約中、利用中、利用後、予約キャンセル、といった状態によってUIが微妙に変わり、これを細かい変更の度に全ての画面で確認するのは大きな手間でしたが、previewによりまとめて確認することができました。

f:id:kankak_inc:20220124153758p:plain
状態別の利用履歴画面一覧

あとはUIテストが非常にやりやすくなりました。これはComposeで助かったというよりも、宣言的でstatelessに作った結果、testabilityが高くなった、という感じでした。

f:id:kankak_inc:20220124163725p:plain

リッチな表現

アニメーションが扱いやすくなりました。Viewのアニメーションでは、xmlで表現できない部分をコードで記述したりして、混沌の原因になることも多かったのですが、Composeでは、そもそもxmlを使用しませんし、APIも簡潔で扱いやすくなっていると感じました。 また、Accompanistには、ローディング中に仮のUIを表示するPlaceholderという機能があります。Modifierの仕組みを使って実現しているのが面白く、View時代には実装しづらかった部分も楽に実装することができました。

f:id:kankak_inc:20220124154131p:plain
左:読み込み後、右:読み込み中

困った点

逆に、困った点は次の3つです。

  • Kotlinバージョンのロックイン
  • アプリサイズの増大
  • 成熟度合い

1つ目は開発途中にKotlin 1.6がリリースされて、アップデートを試みました。しかしComposeがKotlinのバージョンを指定するため、Composeが対応するまでKotlinのバージョンが更新できない、という事態に陥りました。2つ目は、ViewとComposeが共存する上で避けられない問題です。ComposeだけでなくAccompanistやCoil composeなど、周辺ライブラリも色々と導入した結果、Compose導入前後で数MBのサイズ増加が確認できました。最後は時間とともに解決していく問題だとは思うのですが、一部のAPIがExperimentalであったり、日本語入力の扱いなどで怪しい挙動が何点か見つかりました。

課題

最後に、課題として残っている部分を挙げておきます。

コード規約

composableな関数は名前がPascalCaseであったり、通常の関数とは異なる扱いをしますが、named argumentをつけるか否かは書く人によってブレそうだな、と感じました。今回は保守的に「composable functionの呼び出しではnamed argumentを必須とする」としましたが、単一の引数しか持たない場合や、modifierは例外的にnamed argument無しを許容してもいいかもしれません。

// 悩ましい

Text("こうするか")
Text(text = "それともこうするか")

共通コンポーネントの設計

Composeでコードを書いていると、アプリ全体で共通して使いたいUIが必ず出てくると思うのですが、それらのAPIをどう書くべきか、記述の柔軟性と簡潔性のトレードオフをどうすべきか、ということに関しては上手く答えが出ませんでした。例えばComposeのButtonはSlot APIにより柔軟性の高い記述を実現しています。

@Composable
fun Button(
  ...
  content: @Composable RowScope.() -> Unit
)

// テキストボタンとして使う場合
Button(...) {
  Text("Button")
}

ライブラリの設計としては良い方針に見えますが、アプリ内では決まったパターンのボタンしか使わないので、ラウンジ開発では、AppButtonというアプリ内の共通ボタンを定義しました。

@Composable
fun AppButton(
  text: String,
  ...
) {
  Button(...) {
    Text(
      text = text,
    )
  }
}

// テキストボタンとして使う場合
AppButton("Button")

記述が簡潔になり、一見すると良さそうに見えますが、例外的なパターンというのはやはり出てくるものです。 大体は上記の方針で上手く行ったものの、「ある画面では例外的に太字にしたい」、「別の画面では文字数が多いので少し文字を小さくしたい」といったパターンが出てきてしまい、徐々に破綻してくる、ということがButtonの例に限らず何度かありました。このあたりはAtomic Designやデザインシステムの話とも絡めて、上手く構造化していく必要があるのかもしれません。

おしまい

というわけで、Composeを使用したラウンジ開発の振り返りでした。実際に動いている様子を見たい方はぜひ、COFFEE Appをダウンロードしてみて下さい! また、カンカクではラウンジ以外にも「Building the next Ordinary. 新しいライフスタイルを作る」大きなプロジェクトが既に走り出しており、一緒に作り上げていく仲間を募集しています。(Androidエンジニアが足りていません・・・!)雑談ベースで話がしたいけど応募はちょっと・・・という方はTwitter等でご気軽に連絡いただければと思います!

おまけ

Composeでの画像読み込みにはCoil Composeを使うことが多いと思うのですが、 COFFEE Appでは元々Coilを使用していたため、Compose採用時にも特に違和感なく導入することができました。 大元のコードベースを書いた、最近はPdMもやられている しほちゃんの先見性にこの場を借りて感謝します🙏

*1:厳密にはマルチプラットフォームも存在します https://www.jetbrains.com/ja-jp/lp/compose-mpp/

エンジニアからPdMになった件。

こんにちは、カンカクでカフェ事業のPdMをやっている しほちゃん @shihochan_jp です。
カンカクへエンジニアとして入社してから2年間はAndroidアプリ開発をメインに担当しました。去年の夏よりだんだんPdM業務をはじめて秋より本格的にPdMとして仕事をしています。

このブログではしほのカンカクでのこれまでこれからについてまとめて、リアル店舗開店などカンカクならではPdM業務について話したいと思います。

エンジニアとしてのこれまで

Androidエンジニアとしての仕事

2019年10月にカンカクにジョインしてから、自社経営カフェのオーダーアプリ COFFEE App の Androidアプリの開発を担当しました。今となってはトレンドと逆行するかもしれませんが、Flutter製のアプリをスクラッチでフルネイティブ化しました。
入社した日にリポジトリ作成からはじめて色々と攻めた技術やライブラリを利用しながらも、予定通り12月にリニューアルできました。

リリース後も、飲み放題の月額メンバーシッププランの開発やメニュー画面リニューアルなど大きな改修もこなしてきました。

PdMとしてのこれから

エンジニアからPdMになるにあたっての不安

自分がPdMになるにあたって社長やマネージャーと何度か1on1を実施し、率直な不安や期待値調整を丁寧に実施しました。最初から迷いなく進んだかと言うとそんなことはなくかなり不安がありました。

  1. コードを書けない不安
    分析のQuery以外のコードをほとんど書かなくなって3ヶ月が立ちましたが、振り返っても自分はコードを書くことが単純に好きだったんだなぁと感じています。仕事上のストレスのほとんどは対人関係によるものだと思っているので、自らのコードと向き合う環境を捨て、ストレスの渦の中へと突撃することには大きな不安がありました。

  2. 技術キャッチアップが遅れる不安
    現在リリースを控えているラウンジ機能は、Jetpack Composeを利用して開発されています( こちらのブログもぜひご覧ください )。後任のAndroidエンジニアのコードレビューも担当しているのですが、プロダクトのコードを書いていないので満足のいくキャッチアップができていないです。時間を見つけて手元で動かしてみてなんとかレビューしている形です(いつもレビューが遅れてすみません)。

  3. 今後のキャリアの不安
    今まで8年間、エンジニアとして仕事をしてきたキャリアを一度ゼロへリセットしてしまうのではないかという不安がありました。またエンジニアとして仕事がしたくなった際に錆びついた技術力が理由となり選択肢が狭まってしまうこともあるかもしれません。

なぜPdMにチャレンジすることにしたのか

ここまでたくさんの不安を吐露したわけですが、最終的にはPdMを引き受けることにしました。

  1. 技術の分かるPdMとしてのキャリア
    店舗側を担当する非エンジニアとの施策策定や意思決定の場においてCOFFEE Appをはじめ、システムの概要がわかっている人材が必要とされていました。今までの仕事の中でも課題感を感じることが多かったことなので、自分がPdMになることでエンジニア→PdMというキャリアの答え合わせをしたいと思いました。

  2. 一番店にいるエンジニア
    他の会社にはないカンカクならではの魅力として実店舗が存在することが挙げられます。自分たちが開発したプロダクトが店舗で実際に利用されているところを見ることが自分にとっての仕事のモチベーションにもなっています。自分は都内在住で自転車移動なこともあり、頻繁にお店に出向き積極的にコミュニケーションを取っています。現場でのコミュニケーションを通してスムーズにPdM業務へ移行できるイメージを持つことができました。

  3. 事業グロースのためにできることはなんでもするマン
    カンカクへは立ち上げフェーズから参画することができ、かなり古株社員になってきました。カンカクへ入社を決めた際にも自分は技術を極めることよりも新しい事業を創りたいという気持ちが強くあったことを覚えています。今一度、会社を見たときに自分ができること、自分が活きることとしてPdMの道があるのであればチャレンジしたいと思いました。

色々書きましたが、社長とマネージャーからの「チャレンジしてみてやっぱりエンジニアが良かったらまたエンジニアに戻ろう」との言葉と、今までの仕事や自分の強みを活かせる仕事であると思ったのが大きなところです。

カンカクのカフェ事業PdMのお仕事

カンカクではリアル店舗を経営しているので、通常のWeb事業のPdMでは経験しないような業務があります。多くの機器や工事もあるので意思決定後の戻しが致命的な問題になることもあります。ここではカンカクならではの仕事について簡単にお話したいと思います。

新店舗開店業務

カンカクでは去年1年で4つの新店舗を開店させました。プロダクトチームとして新店舗の開店を補助するタスクは一覧化して管理しており、開店を重ねる度にかなり洗練されてきました。開店が近づくと各タスクは毎日のStandup Meetingで進捗を確認する時間を設けています。

f:id:shihoochan:20220118220930p:plain

下北沢開店タスク

タスク内容としては

  • コーポレートサイトへ新店舗情報を追加
  • Slackへの売上通知Botの追加
  • オペレーションiPadのセットアップ
  • ネットワーク構築とWiFiセットアップ

など多岐に渡ります。担当は各メンバーに手を上げてもらうケースもあれば、属人化しないように複数人で対応するものもあります。中には特別な技術を必要としないタスクもありますが、情報セキュリティなどの観点からエンジニアで補助を行っています。PdMとしては、こちらの一覧の管理、ディレクション、セキュリティ意識向上のための周知・徹底などを行っています。

店舗との連携業務

実店舗では本当に様々なことが起こります。自社製の店舗コンソールやアプリなどWebの技術を多く利用して店舗運営をしているので、店舗スタッフには通常の飲食業よりは高いITリテラリーが求められます。店舗側とは定例を設定し定期的に情報共有を行う以外でも日々の業務を効率化するためにたくさんの仕組みを導入しています。

  • Slackを活用したオペレーション
    カンカクではアルバイトスタッフさんにもSlackに参加してもらっています。心理的安全性を確保しながら業務を効率化するためにルール整備や改善などを日々実施しています。

    • help-meチャンネル
      スタッフが日々の業務の中で分からないこと、困っていることがあればこちらのチャンネルに書いてもらいます。(自分がすぐ反応しています(๑•̀ㅂ•́)و✧)

      f:id:shihoochan:20220118214504p:plain

      help-meチャンネルの活用例

    • newsチャンネル
      調理オペレーションの変更やアプリの新機能リリースなどの情報が投稿されるチャンネルです。各店舗の店長と分担しながら情報共有とオペレーションの徹底を行っています。

  • 開発チーム発信のプロダクト・店舗業務改善
    前述の通り、自分は普段から積極的に店舗へ来店するようにしています。PdMとして店舗でのオペレーション課題のキャッチアップ、リリースした機能が意図通りに利用されているかの確認などを行っています。カンカクではエンジニアによる店舗での課題キャッチアップがきっかけとなった施策や機能がたくさんあります。こちらについてはまた別の機会にブログにできればと思います。

おわりに

今回は振り返りも含めてかなりエモめの投稿になってしまいました。プレスリリースで発表させていただきましたが、カフェ事業としては1月20日に初となるラウンジ業態の店舗を開店します。カフェ事業以外でも弊社のビジョンである「Building the next Ordinary. 新しいライフスタイルを作る」大きなプロジェクトが控えています。カンカクでは、一緒に新しいライフスタイルを作り上げていく仲間を募集しています。少しでも興味がある方は是非一度お話しましょう。

Jetpack Compose: 文字は何色か

こんにちは、カンカクでAndroidエンジニアをやっている @haru067です。
最近はJetpack Compose(以下、Compose)をゴリゴリに書いています。

ということで、今回はComposeの話をしたいと思います。
(執筆時のComposeのstableバージョンは1.0.5であり、これを想定して説明します。)

TextFieldとエラーラベル

Composeには文字入力を扱うComposableとして、TextFieldがあります。

@Composable
fun Example() {
    TextField(
        value = "value",
        onValueChange = {},
        label = {
            Text("Error label")
        },
        isError = true,
    )
}

f:id:kankak_inc:20211221122617p:plain

labelを設定すると、入力文字の上にラベルが表示されます。isError = trueを設定したときにはエラー表示となり、ラベルは赤色で表示されます。

ここでクイズ

次の2つのComposable、Quiz1Quiz2を実行したとき、エラーラベルの色はそれぞれ何色になるでしょう?

@Composable
fun Quiz1() {
    MaterialTheme(typography = Typography(body1 = TextStyle(Color.Green))) {
        TextField(
            value = "value",
            onValueChange = {},
            label = {
                Text("Error label1", color = Color.Blue, style = TextStyle(Color.Yellow))
            },
            isError = true,
        )
    }
}

@Composable
fun Quiz2() {
    MaterialTheme(typography = Typography(body1 = TextStyle(Color.Green))) {
        TextField(
            value = "value",
            onValueChange = {},
            label = {
                Text("Error label2")
            },
            isError = true,
        )
    }
}
1. Color.Green
2. Color.Blue
3. Color.Yellow
4. エラーの赤色
5. それ以外

正解

次のリンクを押して、答えを確認してみてください。

いかがでしたでしょうか? 両方とも正解できた方はおめでとうございます! このあとの文章を読む必要はなさそうなので、こちらをクリックしてください。

正解できなかった人も安心してください。このあとしっかり解説します。

解説: Quiz1

Quiz1はそこまで難しくありません。Textの実装を確認すると、Textは次のようなロジックで文字色を決定していることがわかります。

  1. 引数color: Colorが指定してある場合、colorを使用する
  2. 引数style: TextStylestyle.colorが指定してある場合、style.colorを使用する
  3. それ以外の場合はLocalContentColorを参照する(後述)

Quiz1Text("Error label1", color = Color.Blue, ...)となっており、colorが最優先された結果、エラーラベルはColor.Blueになります。

ということで、これは単純な優先順位の問題でした。厄介なのはQuiz2です。Quiz2では、Textに何も指定していません。単純に考えるとエラーの赤色が適用されそうですが、実際にはそうなりませんでした。謎ですね。

これを理解するには、CompositionLocalについて知る必要があります。

CompositionLocal

CompositionLocalは、Composableに対して暗黙的にデータ渡すための仕組みです。
・・・と言っても理解しづらいと思うので、まずは例を見てみましょう。

@Composable
fun CompositionLocalExample() {
    MaterialTheme {
        Column {
            // MaterialThemeがデフォルト値としてContentAlpha.highを設定
            Text("High alpha") 
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("Medium alpha")
                CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
                    DescendantExample()
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // Composableを跨ぐ場合も適用される
    Text("Disabled alpha")
}

f:id:kankak_inc:20211221123138p:plain:w300

Textに何も指定していないにも関わらず、alpha値が変化しています。
Textは内部でLocalContentAlpha.currentという値を参照しており、CompositionLocalProviderを通じてLocalContentAlphaに値を設定することでalpha値が暗黙的に変化する、という仕組みになっています。

なんて危険な仕組みだ!と思った人は嗅覚が鋭いです。知らないうちに値が変わるということは便利であると同時に、問題が発生したときのデバッグを困難にします。実際、公式ドキュメントでも使うべき/使うべきでない用途については丁寧に解説されています。 developer.android.com

では逆に、これを使うべきタイミングはいつでしょうか?典型的な例はThemingです。先程の例ではMaterialThemeで囲った箇所のalpha値が変化していますが、MaterialThemeの実装を覗くと、CompositionLocalProviderを使用していることがわかります。

脱線: provides

LocalContentAlpha provides ContentAlpha.mediumという表記を見て「ん?」と思った方は多いのではないでしょうか。 provides中置記法で、ComposeというよりはKotlinの機能です。LocalContentAlpha provides ContentAlpha.mediumLocalContentAlpha.provides(Content.Alpha.medium)と実質的に同じです。

解説: Quiz2

さて、CompositionLocalについて学んだところで、Quiz2に戻りましょう。

@Composable
fun Quiz2() {
    MaterialTheme(typography = Typography(body1 = TextStyle(Color.Green))) {
        TextField(
            value = "value",
            onValueChange = {},
            label = {
                Text("Error label2")
            },
            isError = true,
        )
    }
}

Quiz2ではMaterialThemetypographyを指定していました。 実装を確認すると、MaterialThemeではtypography.body1に対して CompositionLocalProviderLocalTextStyleを設定していることがわかります。

ここで Textの文字色の決定ロジックを再確認しましょう。

  1. 引数color: Colorが指定してある場合、colorを使用する
  2. 引数style: TextStylestyle.colorが指定してある場合、style.colorを使用する
  3. それ以外の場合はLocalContentColorを参照する

ポイントは2です。styleのデフォルト値は LocalTextStyle.currentなのでQuiz2におけるTextstyleの値はMaterialTheme(...)でprovideされたスタイルになります。 この結果、style.colorMaterialTheme.typography.body1.colorということになり、Color.Greenが優先された、となるわけです。

エラー時の振る舞い

残る最後の疑問は「エラー時の赤色はどうやって実現しているのか?」です。

余白が少ないので細かい流れは追いませんが、TextFieldの実装を追っていくと、最終的にはここにたどり着きます。要するに、TextFieldlabelLocalContentColorをprovideしており、label = {...}内に記述されたText

3.それ以外の場合はLocalContentColorを参照する

の規則に則って赤色になるわけです。おしまい。

おわりに

カンカクでは、Jetpack ComposeやCompositionLocalに興味のあるAndroidエンジニアを募集しています。 気になる方は以下のリンクよりご気軽にご応募ください!