Jetpack ComposeのTextFieldで文字列を中央表示し、placeholderを表示しているときだけカーソルを左端に寄せたい

こんにちは、Androidエンジニアの @syarihu です。mikanでは副業メンバーとしてお手伝いをさせていただいています。

今回はJetpack ComposeでのちょっとしたTipsとして、TextFieldの中央にplaceholderを表示し、カーソルをplaceholderの左端に寄せる方法について紹介します。

実現したいこと

文章だけだと何を言っているのか分かりにくいと思うので、とりあえず最終的にどういう形にしたいのか、デザインを見てみましょう。

これはmikanの初回起動時に表示されるオンボーディング画面内のニックネーム入力フォームです。中央に「れもん」というplaceholderを表示し、その左端にカーソルが置かれています。

一見簡単そうに見えますが、これを実現するにはちょっとした工夫が必要です。

普通にやろうと思うとどのような課題があるのか、それをどう解決したのかを順を追って説明します。

Jetpack ComposeのTextFieldでplaceholderを中央配置したときの挙動

ComposeのTextFieldで普通にplaceholderを中央配置しようとすると、次のようなコードになります。

var nickname: TextFieldValue by remember { mutableStateOf(TextFieldValue()) }
Box(modifier = Modifier.padding(20.dp)) {
  OutlinedTextField(
    value = nickname,
    onValueChange = { textFieldValue ->
      nickname = textFieldValue
    },
    shape = RoundedCornerShape(8.dp),
    colors = TextFieldDefaults.textFieldColors(
      // ...
    ),
    singleLine = true,
    placeholder = {
      Text(
        text = "れもん",
        color = Color.hint,
        textAlign = TextAlign.Center,
        modifier = Modifier.fillMaxWidth(),
        fontSize = 16.sp,
      )
    },
    textStyle = LocalTextStyle.current.copy(
      textAlign = TextAlign.Center,
      fontSize = 16.sp,
    ),
    modifier = Modifier.fillMaxWidth(),
  )
}

これを実行すると、次のように表示されます。

このように、普通にやろうとするとテキストを中央配置している都合でカーソルがplaceholderの真ん中に表示されてしまうことが分かります。

これをどうにかカーソルだけ左に持っていく方法を考えてみます。

Jetpack ComposeのTextFieldでplaceholderを中央配置しつつ、カーソルをplaceholderの左端に持っていく方法を考える

カーソルの表示は入力された文字列に依存しているため、当然入力文字列が存在しなければカーソルの位置をずらすことはできません。また、文字列を入力すると通常のplaceholderの仕組みを使うことはできません。

そのため、通常のplaceholderの仕組みを使わないアプローチとして、TextFieldの入力文字列が空のときにplaceholderの代わりにコードから文字列を入力し、placeholderとして入力されている文字列があるときは入力文字列の色をplaceholderの色に変えてみることを考えました。

はじめに、placeholderの表示有無をshowPlaceholderとして持っておき、showPlaceholderがtrue(テキストが空)のときにテキストの色をplaceholder用に変えて、テキストが入力されたときにonValueChanged内でsuffixにあるplaceholderを削除して改変するようなコードを書いてみました。

var nickname: TextFieldValue by remember {
  mutableStateOf(TextFieldValue())
}
val showPlaceholder: Boolean by remember(nickname.text) {
  mutableStateOf(nickname.text.isEmpty())
}

Box(modifier = Modifier.padding(20.dp)) {
  OutlinedTextField(
    value = if (showPlaceholder) TextFieldValue(text = "れもん") else nickname,
    onValueChange = { textFieldValue ->
      nickname = textFieldValue.copy(text = textFieldValue.text.removeSuffix("れもん"))
    },
    shape = RoundedCornerShape(8.dp),
    colors = TextFieldDefaults.textFieldColors(
      textColor = if (showPlaceholder) Color.hint else Color.textMain,
      // ...
    ),
    singleLine = true,
    placeholder = {
      Text(
        text = "れもん",
        color = Color.hint,
        textAlign = TextAlign.Center,
        modifier = Modifier.fillMaxWidth(),
        fontSize = 16.sp,
      )
    },
    textStyle = LocalTextStyle.current.copy(
      textAlign = TextAlign.Center,
      fontSize = 16.sp,
    ),
    modifier = Modifier.fillMaxWidth(),
  )
}

これを実行すると、次のようになります。

この対応でカーソル位置を左に寄せることは実現できました。

しかし、placeholderから切り替わったタイミングで一文字目が自動で確定され、2文字目以降しか変換できなくなってしまいました。placeholderが変わったときにTextFieldが内部で持っているvalueと違うTextFieldValueが設定されてしまい、そのタイミングで1文字目が自動で確定されてしまうのだと思います。

また、次のようにTextFieldValueのcompositionを一文字目から明示的に指定するのも試してみました。

OutlinedTextField(
  value = if (showPlaceholder) TextFieldValue(text = "れもん") else nickname,
  onValueChange = { textFieldValue ->
        val newText = textFieldValue.text.removeSuffix("れもん")
      nickname = textFieldValue.copy(
      text = newText,
      composition = TextRange(0, newText.length)
    )
  },

これを実行すると次のようになります。

先ほどよりもさらにひどくなり、すべての文字列が未確定状態となっているものの、変換候補が表示されず別々の文字列として確定されてしまいました。

TextFieldValueは別の値に変えようと思うとプロパティが読み取り専用のためcopyすることになるのですが、copyすると値は引き継がれますが別のインスタンスが生成されます。

ユーザーが入力し、onValueChangedで通知された値をcopyしてTextFieldValueを別のインスタンスにしてしまうと、TextField内で保持しているものと違うTextFieldValueとなってしまうため、入力途中の文字列としては扱われなくなってしまうことがわかりました。

そのため、この方法ではカーソル位置を一番左にすることはできますが、入力を自然に行うことはできないため、このアプローチは使えないことがわかりました。

VisualTransformationを使って入力文字列が空のときの見た目だけ変えて、カーソルを常に左端に寄せつつplaceholderっぽいものを作る

若干諦めかけていたものの、ComposeのTextFieldにはVisualTransformationという仕組みがあることを思い出しました。

VisualTransformationは例えばパスワード入力時などに実際の入力文字列とは別に見た目だけマスクするなどの用途で使用できるものですが、そういった既に用意されているVisualTransformationとは別に、独自でVisualTransformationを作ることもできます。

これをうまく使って、入力文字列が空のときに見た目だけplaceholderっぽいものを表示して、なにか入力されたら入力文字列をそのまま表示してあげれば、実際の入力文字列に影響を与えずにうまく動作させられるのではと考えて試してみました。

実際に作ったVisualTransformationは次のようなコードです。

private fun placeHolderVisualTransformation(
  placeholder: String,
): VisualTransformation = VisualTransformation { text ->
  val showPlaceHolder = text.text.isEmpty()
  TransformedText(
    AnnotatedString(text = text.text.ifEmpty { " $placeholder " }),
    object : OffsetMapping {
      override fun originalToTransformed(offset: Int): Int =
        if (showPlaceHolder) 0 else offset

      override fun transformedToOriginal(offset: Int): Int =
        if (showPlaceHolder) 0 else offset
      }
  )
}

placeholderとして表示する文字列を引数にとる関数を作成します。入力テキストが空の場合は表示文字列をplaceholderに変えておきます。

originalToTransformedとtransformedToOriginalは入力文字列とTransformedTextでテキストの長さに違いがある場合にカーソル位置を調整するものですが、placeholderとして扱いたいため常に左端にカーソルがいくように、placeholderの表示中はoffsetを0として返すようにします。

あとは、TextFieldのvisualTransformationに設定しつつ、次のようなコードを書きます。

var nickname: TextFieldValue by remember {
  mutableStateOf(TextFieldValue())
}
val showPlaceholder: Boolean by remember(nickname.text) {
  mutableStateOf(nickname.text.isEmpty())
}

Box(modifier = Modifier.padding(20.dp)) {
  OutlinedTextField(
    value = nickname,
        onValueChange = { textFieldValue ->
      nickname = textFieldValue
    },
    shape = RoundedCornerShape(8.dp),
    colors = TextFieldDefaults.textFieldColors(
      textColor = if (showPlaceholder) Color.hint else Color.textMain,
      // ..
    ),
    singleLine = true,
    textStyle = LocalTextStyle.current.copy(
      textAlign = TextAlign.Center,
      fontSize = 16.sp,
    ),
    modifier = Modifier.fillMaxWidth(),
    visualTransformation = placeHolderVisualTransformation(placeholder)
  )
}

あくまで入力文字列の見た目を変えているだけなので、最初に考えたアプローチと同じようにテキストカラーはplaceholderの表示中はplaceholderの色に変える必要があります。

本来のTextFieldのplaceholderはvisualTransformationを使うことで不要になったので、placeholder引数の指定は削除しています。

これを実行してみると、次のようになります。

1文字目が自動で確定されることなく、入力した文字列がすべて変換対象となり変換候補も表示され、デザイン通りの見た目で自然に入力させることができました 🎉

おわりに

若干諦めかけましたが、なんとか期待どおりのUIを作ることができて良かったです。もし似たようなケースを実装することがあれば、VisualTransformationを試してみてください。

最後に、僕がお手伝いしているmikanでは正社員のAndroidエンジニアを募集しています。ご興味ある方は下記のリンクからぜひご応募ください!

また、話だけでも聞いてみたい!カジュアル面談してみたい!という方も、応募メッセージにその旨を書いてお気軽に送ってください!よろしくお願いします!

CircleCIのセキュリティアラートに対応した時の振り返り

 

株式会社mikan バックエンドエンジニアのgamiです。最近はFactorioにハマってしまい黙々と工場を建設しています。

2023年の年明けにCircleCIからセキュリティアラートが通知され、Twitterのトレンドになるほど多くの皆さんから反応があったことをご存知でしょうか?

circleci.com

mikanでもCircleCIを利用していたため、シークレットのローテーションなどの対応を実施しました。

この記事は社内用に残した対応履歴のドキュメントを社外用に書き直し、振り返りを付け加えたものです。

TL;DR

  • 結果的にmikanのシステムには問題ありませんでした。
  • CircleCIをアクティブに利用していたプロジェクトは1つだけだったため、CircleCIで組んでいたワークフローは他のプロジェクトに合わせてGitHub Actionsへ移行しました。

タイムライン

2023/01/05 11:34 JST メール受信

CircleCI Security Alert - 4 Jan 2023 - Rotate any secrets stored in CircleCIという件名でメールが来ました。

早速反省点ですが、この時点ではすぐに対応を開始することができませんでした。

この日はmikanの期初のタイミングで全体的にバタバタしており、加えてチームメンバーの休暇や突発的な採用業務などが重なったためです。

本来であればメールの内容を確認した上で対応の優先順位を判断すべきでしたが、後回しにしてしまいました。

2023/01/05 19:42 JST 関係者への共有

関係者へセキュリティアラートの共有。

関係者にSlackで周知した際のキャプチャ

正式な表記はCircleCIですが、半角スペースを入れてしまっています。

これからオペレーションを実施するには時間が遅いので、翌日に対応を行うことにしました。

しかし翌日もバックエンドチームのメンバーは休暇予定だったため、代表のkazuさんに協力を依頼しました。

Slackでペア作業の依頼をする様子

翌日を逃すと3連休に入ってしまい、対応がかなり遅れるリスクがありました。

四半期の始まりで各プロジェクトの方針レビューなどがある忙しいタイミングで快諾していただけて本当に良かったです。

Slackで快諾してくれるkazuさんのキャプチャ

楽しそうなkazuさん

2023/01/05 20:27 JST 事前準備

対応方針の書き起こし

翌日に作業実施が決まったため、下記のざっくりと対応計画を立てました。

  1. シークレットのリストアップ
  2. 1.の中から不要な鍵を特定し削除
  3. 使用中のシークレットのうち、他のシステムでも使っているものをリストアップ
  4. 3.で見つけたシークレットを更新

これはよく見ると2番は後回しでも良いです。実際に鍵が漏洩して悪用されていた場合、鍵を無効化することが優先されるため、いかに早く4番を完了させるかが重要でした。

実際の作業では2番よりも4番を優先できたので良かったと思います。

シークレットのリストアップ

mikanでCircleCIが有効になっているプロジェクトは2つありました。mikanで管理しているリポジトリは40件を超えているため、そこから比較するとかなり少ない件数です。

後日CircleCIからシークレットをリストアップするツールが公開されることになりますが、この時点では件数も少なかったので手作業で以下を確認しました。

  • Environment variables
  • Contexts
  • Deploy Key

ここで見つけたものはスプレッドシートでキー名を管理し、翌日の仕分けに利用しました。

2023/01/05 PST CircleCIからの追加情報

CircleCIから追加情報がありました。

(https://circleci.com/blog/january-4-2023-security-alert/Security update 01/05/2023 の部分)

ここではシークレットのローテーションに関する対応手順が書かれていたため、まずは該当の有無を確かめました。

CircleCIからの追加情報をまとめたNotionのキャプチャ

この調査にあたり、CircleCIのコンソールで私の権限では見れないものに関してはkazuさんにも非同期で確認してもらいました。

 

2023/01/06 11:57 JST ローテーションの開始

一人で対応可能なOAuthトークンのリフレッシュ及びDeploy Keyの差し替えのみを実施しました。

Deploy Keyの差し替えは少しだけ罠がありました。

  1. GitHub側のSSH Keysを削除
  2. Circle CI側のDeploy Keysを削除
  3. Circle CI側でAdd Deploy Keys
    • このとき、紐づくGitHubリポジトリのAdmin権限がないと画面上は何も起きない(ブラウザコンソールに404エラーが出力される)

コンソールに出ていたエラーは

{
	"message":"Not Found",
	"documentation_url":"https://docs.github.com/rest/reference/repos#create-a-deploy-key"
}

となっており、GitHubのドキュメントが案内されていたため、GitHubの権限を確認しました。

案の定権限が足りていなかったため、インフラ作業用の強い権限を持つアカウントで該当リポジトリの権限設定を修正してからリトライしました。(具体的には、Adminロールに個人アカウントだけが含まれている状態だったため、チームを招待しました。)

これにより、GitHub側でSSH Keysが更新されたことを確認しました。

2023/01/06 13:37 JST CircleCI-Env-Inspector

念のため、CircleCIから提供されたツールを実行してみました。 

github.com

そのまま動かすとエラーになったため軽くパッチを当てたところ、前日にリストアップした内容に問題ないことが分かりました。

2023/01/06 15:33 JST 追加の調査と手順書作成

ローテーション対象の絞り込み

リストアップした環境変数のうち、明らかに利用されていないものがいくつかあったため、本当に利用されているものだけを絞り込むために調査を行いました。

作業内容としては、対応するリポジトリに対して、CircleCIで設定している環境変数を呼び出している箇所をgrepして確認するという地道なものになりました。

この調査の結果、対象範囲をかなり絞り込むことができました。

ローテーションの具体的な手順書を作成

上記調査をもとに、アプリケーションの動作確認を含むローテーション作業の手順書を作成しました。(AWSの一部サービスを利用するIAMが対象でした。)

  1. IAMで認証情報を新規作成
  2. 検証用の新たな環境変数を作り、1の情報をコンテキストにセット
  3. 検証用の新たな環境変数を参照するようにアプリケーションを修正
  4. アプリケーションをデプロイ
  5. IAMが利用されたことと、AWSのサービスを利用する処理が問題なく動作することを確認する
  6. IAMの古い認証情報を削除
  7. 既存の環境変数を置き換え
  8. 4を再実行
  9. 5を再実行

上記を各環境 (開発環境、ステージング環境、本番環境) で実行します。

 

2023/01/06 16:00 JST ペアでローテーション実施

軽く背景の共有と状況報告を行い、kazuさんとペアで作業を開始しました。

3連休を控えた週末に何度もデプロイをすることになりましたが、一緒に見てもらえたことで焦らずに作業することができました。

不具合なく作業を完了できたのは、ペアで作業したからこそだと思います。

2023/01/06 18:00 JST 頃 サービスアカウントの停止

ひととおりデプロイ作業が完了したため、Firebase CLIトークンを発行しているサービスアカウントを停止しました。

振り返ってみると、トークンは真っ先に無効化しておくべきだったと思います。Firebaseのサービスアカウントを乗っ取られていた可能性があるためです。

mikanはFirebaseに依存したシステムがいくつかあるため、悪用されたときの影響はかなり大きくなってしまいます。

2023/01/10 16:30 JST ログ調査

シークレット漏洩の可能性のあるサービスはAWSとFirebaseの2種類のサービスだったためログの調査を実施しました。

AWSの方は、1つのサービスの許可しか与えられていないIAMのアクセスキーだったため、該当のサービスのログを確認し、不審なものがないかを探しました。

Firebaseの方は影響範囲が広かったため、まずはFirebaseのGUIコンソール上で異常がないかを確認し、その後APIの利用状況などを調べました。

これらの調査の結果を踏まえ、mikanへの問題はなかったと判断しました。

2023/01/18 17:40 JST ワークフローをGitHub Actionsへ移行

mikanでCircleCiを利用しているプロジェクトは2つあり、そのうち1つはメンテナンスが終了していました。

そのため、アクティブにCircleCIを利用しているプロジェクトは1つのみとなり、他のリポジトリのほとんどはGitHub Actionsを利用していたため、今回ローテーションを実行したプロジェクトもGitHub Actionsへ移行することにしました。

GitHub Actionsへ移行するついでにGitHub OIDCを利用するようにワークフローを修正し、よりセキュアな状態に組み替えました。

また、先日発表されたGitHub Actionsの構成変数を利用し、機密性の低いものは暗号化しないことで有事の際の調査に役立つようにしました。

github.blog

終わりに

新年早々大変な作業となりました。本件の対応に当たった各社及び個人の皆様もお疲れ様でした。

mikanでは結果的にCircleCIの利用を辞めてしまいましたが、CircleCIの今回の案内は非常に誠実だったと感じており、好感を持ちました。

CircleCIで実際に何が起きたのかといったレポートも公開してくれています。

これを機に、mikanのセキュリティの甘い部分に関しても改めて話題に挙がるようになってきました。

少ない人数で事業を拡大させつつ守りも盤石にしていかなければならず、一筋縄には行かないかも知れませんが、一つずつ着実に進めていこうと思います。

以上、CircleCIのセキュリティアラート対応の振り返りでした。

2022年Androidエンジニアとしてやったことと今後について

こちらはmikanのアドベントカレンダー24日目の記事になります。

23日目は @hoshitocat から、「負債を計画的に返済していくために、Backendチームでやっていること」でした。 技術的負債は必ず起きていくものなので、負債と向き合うことは必須だと思います。みかんバックエンドチームでの向き合い方がわかる内容で、Androidチームも以前はやっていたんですけど最近負債解消に向き合えてないところがあるので、もう少し明確にやっていきたいと改めて思いました。

mikan で Androidエンジニアをやっている@zukkeyです。

最近は、犬か猫を飼いたくて物件探しをする毎日です。

賃貸のままにするのか、戸建てを思い切って購入するのかに悩んでいます。日々、検討に検討を加速させています。

今回は、今年自分が関わってきたmikan Androidの振り返りをしながらアプリの変化をお見せし、今後どうしていきたいかについて紹介させていただこうと思います。

今年やってきたことの振り返り

New Card & Test Redesign

mikan Android版は長らくカード画面とテスト画面が古いデザインのままでした。

RealmからFirestoreへ刷新するという巨大プロジェクトが動いている中、細々とカード学習画面とテスト学習画面のリデザインプロジェクトをやっていて、実は新しいデザインに変わったのは今年なんです!(リリースされたの今年だったのかー、と自分自身が一番驚いています。もはや遠い昔の話に感じます。)

カード学習画面の変更

4択テスト学習画面の変更

去年から進めていたプロジェクトだったので、無事にリリースできて嬉しかったです。

カード学習の方は特に表示領域が変わったので、以前よりは学習しやすくなっていると思います。

New Subscribe Plan View

今はもうさらに新しい課金プラン画面が登場したので、非常に寿命が短かったのですが、実は課金画面も新しくなっていたんです。

以前はシートが出ていなかったんですけど、より課金のプランがわかりやすいような表示に変わっていました。

ちょっと前の課金View

そういえば、こんなこともやっていたなと懐かしく思いますw

24時間セールが入ったのもこの時でした。この頃から徐々にAndroidiOSの背中を本格的に追いかけていました。

24時間セール

Audio Player

実は初Compose&ExoPlayer導入をしたプロジェクトでした。どちらかといえば、試験導入に近いフェーズでした。

ExoPlayerもComposeもよくわからない状況で、とりあえず公式ドキュメントとサンプルをみて手探りで進めていました。

ExoPlayerもComposeもドキュメントは充実していると思います。ただ、このパターンの実装の経験者が社内にいなかったことと、当時はComposeのサンプルがなかったので、UAMPのサンプルを一番参考にしつつ、自分なりに考えて組み合わせていくということをやっていました。

もともと考えていたアーキテクチャの構成もあんまりマッチしないので、一から考え直しだな〜、と頭を悩ませていましたね。

Audio Playerの一番初期は「プラタン2000」のみ利用できる機能になっていました。

当時は期限が決まっていたので急いで対応する必要があり、未経験ながらExoPlayerとComposeを組み合わせて導入したのがいい思い出です。

自分自身音声周りの実装については初めてやることだったので、なかなか不具合を出してしまい、QAチームには負担がかかってしまいました。

優秀なQAチームにたくさんQAしていただき、バグを直して直して直しましたので、いい状態に仕上げられたのではないかと思います。

DB Project a.k.a Darwin

DB Projectというのは、元々mikanアプリはRealmを使っていたので、データベース周りをRoomとFirestoreを使う方針に切り替えるというデータベース刷新のプロジェクトのことです。mikan社内ではDarwinという名前をつけて呼んでいます。

一応自分も担当していたので、上記にあげたプロジェクトを進めながらリリース後もお問い合わせいただいた件を対応していたりしました。

読み込み速度が遅いというお問い合わせをいただいていたので、リリース後も速度周りの改善を進めていました。

どうやら、オフライン切り替え判定部分が上手くいかずにFirestoreを参照しにいっていて読み込みがうまくいかないとか、オフラインオンラインで使っているうちにデータの不整合が起きてしまう問題とか、さまざまな現象が起きてワタワタして四苦八苦していたのがいい思い出です。

Android側も直したり、バックエンドチームにScriptを作っていただいたりして、皆で対応をしていました。

まだ、問題がある部分はあるので、後ほど記載する「今後について」で紹介いたします。

Toeic Project a.k.a Honda

Toeic Projectは、TOEIC問題演習の形式に対応する問題演習機能を新規追加するプロジェクトです。mikan社内では、Hondaと呼んでいます。

もともと、mikanは英単語アプリだったのですが、英語アプリへ移行するという方針に変わり、その上でiOSでは既に対応していた機能で、AndroidiOSに追いつくべく、問題演習機能の導入をやっていこうというプロジェクトでした。

Audio Playerでやっていて試験導入とはまた別に、Composeをがっつり導入して副業の方とも相談させて一緒にやっていただきながら、いろいろな意見を聞いて実装を進めていきました。

当時は適宜わからない部分はヒアリングもしつつ、iOS側も実装が大変そうということもあったので、自分でiOSのコードを読みにいったりして参考にしたりしました。

また、副業の方の依頼用タスクを作成したり、自分でも実装したりと、慌ただしくも学びの多い期間でした。

Toeicの問題演習機能は、全部で6パターン存在するので、そのパターンごとに学習画面、学習解説シート、学習結果画面を用意する運びとなりました。

なかなか実装工数もかかる大きいプロジェクトだったと思います。

Composeと仲良くなれるいい機会でした。

Toeic Project v2 a.k.a Enhancement of teaching materials

Toeic Projectのv2として、教材充実化というプロジェクトもありました。

最初に出したバージョンでは、mikanオリジナルの教材にしか対応していない形で実装していたので、どの教材がきても同じパターンであれば利用できるようにする実装と、この当時はなかった(先程の画像には載ってしまっているのですが、)リーディングの右上にある学習推奨時間の導入を合わせて行うというプロジェクトでした。

裏で課金対応をしてもらっていたので、自分はこちらの課金プランに応じた教材の充実化をする対応をやっているという流れでした。

このプロジェクトのおかげで、またComposeの実装に詳しくなれたのではないかと思います。

Phrase and Sentence Project

Phrase and Sentence Projectは、「金のフレーズ」「金のセンテンス」といったフレーズシリーズとセンテンスシリーズへの例文学習機能を実装するプロジェクトでした。

このタイプの教材だけに、元々あるカード学習機能、4択テスト機能に加えて、例文機能を追加するという内容でした。

一見すると、Toeicの問題演習機能に激似しているので、サクッと終わるかなと思っていました。

実はそれが罠で、裏側とのロジック部分が異なるので、若干テコ入れをしてあげないといけない、ということがおきました。成長痛を感じたプロジェクトでした。

また、このタイミングで、設定画面のリプレイスを行いました。

教材詳細画面から歯車のアイコンをタップして遷移できる設定シートをComposeで新規で作り替えるという対応も行いました。また一つ画面を新しくすることができました。

実はこの上で、カード学習、4択学習、問題演習、例文学習画面それぞれの設定シートも新しくなっていたりします。

結構いろんな画面をComposeで組んでみて、ComposeはUIを組みやすくて非常に早く簡単にやりたいことを実現できる(肌感)ので、なんだかもうAndroid開発だいぶやりやすくなったなぁと一番感じたプロジェクトでした。

このほかにも、いくつか対応したことがあるのですが、まだ表に出る機能ではなかったり、ちょっとしたことだったりするので、割愛とします。

振り返りのまとめ

今年は、ひたすらアップデートしてiOSの背中を追いかけるために黙々と実装するという1年でした。マラソンで例えるなら、遠く離れてしまい中継先のテレビから見えなかった姿が、もう後ろ姿は見える、というところまできたのではないかと思います。

mikan Androidは少ないメンバーでありながらもComposeで画面を次々と実装していったり、ExoPlayerを導入したり、自分は主にやってはなかったんですがGraphQLを導入したりと、新しい技術を取り入れつつ、いろんな機能を実装していくことができたのではないかと思います。

個人としては、Androidアプリに限らずWebの実装をお手伝いしたり、タスクを依頼したりなど去年とはまた違ったことをやっていました。

やってみる中で、自分は人に依頼するのは苦手ですが、もくもく実装するのは得意な方で新しい分野を学ぶのも楽しくやっていけているので、プレーヤーとしてやっていく方がいいのかもしれないな、と感じた1年でした。

今後について

この1年で、ぶっちゃけmikan Android結構変わったんじゃない?という気持ち、あります。

でも、実はまだまだやりたいことがあります。

自分が考えていて直近対応したいことは、3つです。

  1. 速度改善
  2. 機能差分実装
  3. ダークテーマ

今のところ、通信環境問題で学習がうまくいかないお客様が一定いらっしゃるので、すでに取り組んではいつつも、やりたいといいつつ時間が思うように割けずにいたので、来年こそはより優先して対応したいと思います。

また、iOSAndroidの機能差分がまだあります。まだ教材によっては対応しなければならないことがあるので、それらの差分吸収をしてさらに多くの教材を多くの方に使っていただき、アプリと英語を通して人生の可能性を広げるキッカケ作り、やっていきたいと思います。

あとは、実は裏ではひっそりと進んでいるダークテーマ対応、(途中から止まってしまっていたのですが、)やりたいですね。

ダークテーマを待ち望む声もありますし、最近では標準に近い機能と思いますので、なるべく早く取り掛かっていきたいところではあります。(もしくは、相方がやってくれるのかもしれないので、楽しみにしています。)

さいごに

今回はmikan Androidでやってきたことの振り返りを通して、mikan Androidの進化の過程をお見せできたのではないかと思います。

mikan に興味を持ってくださって、一緒に最高のAndroidアプリを作ってくださるエンジニアやデザイナー募集しております!!

ぜひぜひ、下記リンクよりご応募くださいませ!!!

herp.careers

herp.careers

負債解消は計画的に〜Backendチームの取り組みをご紹介〜

こちらはmikanのアドベントカレンダー23日目の記事になります。

22日目は @3izorin から SaaSのプロトタイプを作るのにNotionがおすすめな件 でした!

note.com

プロトタイプと聞くと、画面があって簡単なワイヤーフレームをイメージしてしまうのですが、情報整理がされていて、それを元に議論するツールと捉えることもできるなと勉強になりました。情報設計する際は上記を参考に Notion を活用してみてはいかがでしょうか?

こんにちは。mikanでBackendエンジニアをしております。 @hoshitocat です。

23日ということで今年も残りわずかとなってきましたね。余談ですが、先日家に友人が来てくれたのですが、部屋が乾燥しているから加湿器使ったほうが良いとオススメされたので、何か買おうかなと思っております。何かオススメのものがあればぜひ教えてください!

さて、今回の記事では、負債の解消にチームでやっていることを紹介しようかと思います。負債の解消に悩んでいる方や、そもそもそこへの取り組みが全くできていなく、課題感を持っている方はぜひ参考にしてみてください。

はじめに

いきなりですが、技術的な負債に関しては皆さんどのように捉えておりますでしょうか?

私は以下を参考にしております。

t-wada.hatenablog.jp

技術負債はチームおよびそのメンバーが成長する上で必ず発生しうるものであり、むしろチームが成長している証拠であると思っています。チームやメンバーが成長するにつれて、以前書いたコードと自分がこれから書こうと思っているコードの乖離が広がっていきます。そのため、この差を埋める作業が必要になります。しかし、この差を埋める作業はプロダクトを前進されるもの(成長させるもの)ではありません。そのため、事業を伸ばすための開発時間を確保することを優先しすぎてしまうことがあると思います。

弊社のサービス英語アプリmikanは650万ダウンロードを超えており、2014年にリリースされて以来、いろんな機能が追加されてきました。私が入社したのは2019年ですが、今ではBackendチームも3人に増え、よりリリースのサイクルが高速化しました。しかし、3人に増えたことで、施策を試すスピードがあがったことや、開発の規模もこれまで以上に大きなものになってきたことなどがあり、今のシステムとチームおよびメンバーの技術負債はより広がっていきました。

負債解消のために導入した仕組み

mikanでは、事業をより成長させるため、英単語アプリから英語アプリへ進化してきたように、直近のやっている施策はより攻めの施策が多く、ガンガン進化しています!

一方で負債解消には手がつけられていない状況が続いており、このままだとドンドン負債が溜まっていってしまう状況となっていました。

そこで、Backendチームでは強制的に負債を解消するための仕組みを導入しました。

負債解消Day

月に一回オフィスに集まって、負債解消だけをやる日を設けました。これを実施する上で、他チームメンバーには以下の2つに協力してもらっています。負債解消をやる上でノイズになってしまうからです。

  1. Slackでの反応が遅れる
  2. MTGは入れない

実際に依頼しているメッセージ

負債解消Dayの実施には、必ず事前準備をしています。負債解消Dayではチーム全員が負債解消の時間とするため、より時間を有効活用する必要があります。

事前準備では、そもそも今月の負債解消やる必要あるっけ?ということから、負債解消でやるべきタスクを一覧し、その上から順番に優先度づけを実施、その順番に着手していくというのをやります。

優先度は最も早く効果が現れるものを選ぶようにしています。 なぜ優先度をこのようにつけるかといいますと、前提として、負債という性質上より早く解消した方が、開発効率や施策の進めやすさ(技術的制約が少なくなる)に影響するものばかりだと思っています。

その中で、より早く効果が現れる = 直近やっている開発orこれからやろうとしている施策に影響の出るもの ということであり、施策の順序は事業成長の優先順位から決められているはずなので、負債の解消もそこに紐づいた順位をつけられる方が良い考えているからです。

また、効果が現れる とは、開発効率といった単純なものもありますし、これから事業成長のロードマップ上、解消しておいた方が良い技術的な問題の両方だと思っています。したがって、技術的には負債となっているけれど、ロードマップに乗ってこないような部分の改修は優先度は低く、ロードマップ上必ず開発が進んでいくような部分に関しては優先度高く改修する必要があると捉えています。

実施するタスクが決まったら、それに必要な情報を集めたり、先に当日のスケジュールまでざっくり決めています。スケジュールが決まっているとやることや動き方に迷わなくなるので、当日の動きがより円滑になるからです。

スプリント毎に負債解消ポイントを10~20%程度設ける

Backendチームでは1週間を1スプリントとし、スプリントの区切りのタイミングでは、次のスプリントのタスクを洗い出したり、見積もりをしています。

そのときに、ベロシティに合わせて、10~20%程度負債解消していこうという取り決めをしています。このルールを設けることで、負債解消Dayではやらないような細かい負債の解消も定期的に解消していくことができます。

負債Backlogを作って負債を記録する

負債は気づいたタイミングで、タスクボードに記録するようにしています。Backendチームはタスク管理をGitHub Issueを使っていますが、負債解消タスクも同様に記録しています。

負債解消Dayやスプリント計画のときに、記録されているタスク一覧から、ピックアップするだけでよいので、負債になっていることを忘れることもないし、負債解消しようと思ったときに、そこから選択するだけで良くなるため、負債解消までのハードルを下げることができています。

負債解消した方がいいIssueたち

負債解消の妥当性について

負債解消をする上で最も気にしなければならないことは、事業成長の速度を鈍化させてはならないということです。そこで、負債解消をしても生産性が鈍化していないことを証明できれば、負債解消の時間を確保することの納得感が得られると考えました。また、上記でやってきた取り組みの効果をより実感できると思います。

やっていることは至ってシンプルで、以下の3つです。

  • 開発時間の計測
    • 時間計測には、 clockify というツールを導入しています。簡単に計測できるし、 チーム全体やメンバーなどのさまざまな条件で、集計結果をみることができるので便利です。
  • タスクへのポイントづけ(計画と実績の両方)
    • 計画と実績の両方あることで、見積もりの妥当性を検証できます。
  • 生産性の指標として four keys を導入
    • 長くなるので、これは後ほど紹介記事書きたいと思います。

Backendチームの1時間あたりの消化ポイントが下がっていないことや、four keysの指標から、事業成長の速度を鈍化させていないことを計測できるようになりました。

まとめ

今回は、負債解消のために弊社のBackendチームがやっている取り組みについてご紹介させていただきました。

負債解消は仕組みかしないとなかなか取り組みが難しいところではありますが、仕組みさえ導入してしまえば、着実に解消を積み上げていくことができるので、ぜひ参考にしていただけたら幸いです。

さいごになりますが、弊社では一緒に働く仲間を募集しています。もし、ご興味のある方は以下のリンクからでも良いですし、私にDMしていただいても良いので、お気軽に声かけてください!

herp.careers

特にデザイナーの方募集中です!!

ご興味ある方は何卒お声がけください!!社内のデザイナーにもお繋ぎします 🧑‍🎨

herp.careers

それでは、また次回もお楽しみに。

mikanの実例で学ぶGoogle Play Billing Library V5

はじめに

こんにちは!mikanでAndroidエンジニアをしている、gumiojiです!

twitter.com

こちらはmikanのアドベントカレンダー 18日目の記事になります!

1つ前の17日目の記事は@chiakiの「CSがQAやったら意外とメリット多かった話」でした!

CSでmikanに入社したchiakiさんがQAに転身したのですが、ほんとに観点が鋭くて日々すごく助かってますw

余談ですが、この頼もしいQAチームのおかげで、上位プラン導入はリリース後、驚くほどに何も問題がなくて逆に怖いくらいです…w

今回mikanでは、今までの通常プランのmikan PROの上位プランにあたる、PRO PLUSを導入する際にAndroidの課金ライブラリである、Billing Libraryを最新のv5に同時にアップデートしたので、その際の辛かったことポイント2点を軽い備忘録として残そうかなと思います!

PRO PLUSとは? どういった背景で導入したの?という方は前回の僕の振り返り記事のPRO PLUS対応欄をご覧ください!!

mikan-tech.hatenablog.jp

対象読者

これからAndroidでBilling Library v5を新規で導入したい方、v4利用中で移行したい方

Billing Library

Google Playの課金システムにアプリから楽にアクセスできるLibraryと思ってもらえたらと思います!2022年12月16日現時点ではVersion5が最新です!

いつも最新版は2年間のサポートとなりますので、v5は再来年の5月までには利用しておきましょう!

新規導入の方

サクッと確認したい方、手元にPCなどがない場合は公式の導入ガイドを見ていただければと思います!

developer.android.com

既存から移行する方

こちらも移行ガイドを用意してくれているので、それに従いましょう!

developer.android.com

共通: 手を動かしながら確認したい方

お時間がある方、手元にPCがある方はCodelabsが用意されているので、実際にコーディングなどを通じて学んでいくと良いと思います

補足: Google codelabsとは?

Googleが用意している、各お題毎にガイド付きのチュートリアルを用意したハンズオン形式のコーディング体験ができるコンテンツです!

codelabs.developers.google.com

それでは実際に何が辛かったのかの紹介をしていこうと思います!

実装でハマった部分

つらみ1: 新しい構造の扱いにくさ

v5で一番大きな変化といえば、課金モデルが変わったことです。

Before: v4まで

  • 1Subscription(aPlan + aOffer)

After: v5から

  • 1Subscription
    • aPlan
      • aOffer
      • bOffer
    • bPlan
      • ・・・
    • cPlan

図で表現すると以下のような形になり、左側がBeforeで、右側がAfterとなります

Beforeは全てが1つの塊として作成されてしまっていたため、柔軟性がなく、別期間を用意したい、特別な割引などを別途提供したいなどの際に1つ1つ新しいSubscriptionを作成してました。

ex.

  • Subscription
    • mikan PRO 1ヶ月
  • Subscription
    • mikan PRO 6ヶ月
  • Subscription
    • mikan PRO 1年間
  • Subscription
    • mikan PRO 1年間
      • 半額セール

Afterからは階層的な構造になったことで、柔軟性が増しました。

今までと変わらず、一番上にSubscriptionが存在し、その下に期間などを表すBasePlanが存在し、最後に割引や無料トライアル等といった提供を行うOfferが存在します

ex. 以下のような形になり、新しい期間やセールなどをを追加したい、変更したい場合に追加することが可能です

  • Subscription
    • mikan PRO
      • 1ヶ月
        • 無料トライアル
      • 6ヶ月
        • 無料トライアル
      • 1年間
        • 無料トライアル
        • 半額セール

お〜!いいやん!って思ったんですけどね、BasePlanとかOfferの取得どこ???

そんなものはないぜ!!!全部tagで指定しろよな!!とのことらしいです。。。。。

「t…tag….? なんですかいそれは…?」

BasePlanやOfferなどはコード上で取得をする際にtagを使って取得するように作られています。

実際にplay consoleの画面を見てみましょう!画像内のタグという箇所になります。

BasePlan

Offer

ここまではまだいいんですが、BasePlanのtagを指定したのはいいけど、そのtagはどこで使うんですかい?? どこで指定するんですかい?? なくないですか?? Googleさん??()

これに関しては、ProductDetails.SubscriptionOfferDetailsのofferTagsにbasePlanもofferのtagもまとめて入ってくるみたいです….そんなこと1mmも書いてないんですけど….(せめて分けるか分かりやすいのを用意してほしかったよ…offerって書いてますやん…)

この辺どこに情報が隠れてるかというと、Codelabsのsample githubに書いてあります

github.com

上記sampleより、以下指定されたtagをofferDetailsの中から該当するものがあるか、あればそのofferDetailsを返すものです

   /**
     * Retrieves all eligible base plans and offers using tags from ProductDetails.
     *
     * @param offerDetails offerDetails from a ProductDetails returned by the library.
     * @param tag string representing tags associated with offers and base plans.
     *
     * @return the eligible offers and base plans in a list.
     *
     */
    private fun retrieveEligibleOffers(
        offerDetails: MutableList<ProductDetails.SubscriptionOfferDetails>,
        tag: String
    ): List<ProductDetails.SubscriptionOfferDetails> {
        val eligibleOffers = emptyList<ProductDetails.SubscriptionOfferDetails>().toMutableList()
        offerDetails.forEach { offerDetail ->
            if (offerDetail.offerTags.contains(tag)) {
                eligibleOffers.add(offerDetail)
            }
        }

        return eligibleOffers
    }

上記のtagを渡してる呼び出し元は以下で、tag = MONTHLY_BASIC_PLANS_TAG って書いてありますね….

viewModel.buy(
  productDetails = it,
  currentPurchases = currentPurchases,
  tag = MONTHLY_BASIC_PLANS_TAG,
  activity = activity
)

う〜〜〜〜ん、めんどくさいっ()

mikanではどうしたか

実は既存の課金アイテムはv5がリリースされた際に強制的に以下のような新しい形に強制変換されており、新規で追加するプランではあったのですがこの形に寄せました!

理由は扱いにくかったのとプランはそんな頻繁に追加はしないためです。

Offerは頻繁に追加したくなると思うので、そこだけ恩恵を受けれれば良いと考えました。

そのため、今回新しく追加したPRO PLUSには本来は3つのbasePlanが存在しますが、1Subscription: 1BasePlanとして扱ってるため、3つのSubscriptionとして作成しています!

つらみ2: Offerの融通の効かなさ

今回新たに追加されたOfferの選択ですが、こちらはBillingFlowParams.ProductDetailsParams.BuilderのsetOfferTokenにofferDetails.offerTokenを渡してあげることで指定できます!

先程上記で紹介したように選択すべきofferをtagを利用し、複数のofferDetailsから見つけ出して上げる必要があります。

val eligibleOffers = emptyList<ProductDetails.SubscriptionOfferDetails>().toMutableList()
offerDetails.forEach { offerDetail ->
  if (offerDetail.offerTags.contains(tag)) {
    eligibleOffers.add(offerDetail)
   }
}

return eligibleOffers

見つけたofferのtokenをbillingParamsのofferTokenに渡すのですが、offerなんて提供してないよ!だからいらないんじゃない??って方も空でいいので呼び出してあげてください。じゃないと機能してくれません

val billingParams = billingFlowParamsBuilder(
                    productDetails = productDetails,
                    offerToken = offerToken // 必ず呼ぶんじゃぞ
                )

なんか動かないな〜と思ったら、空でよいので呼び出してあげてください

→ ちなみに2022/08時点では、空をセットした場合は内部でoffer適用含め、最も安い価格になる条件になってました

このofferTagなんですが、消化した後も反映されるまでに少し時間がかかり、流れてくるのが混乱を招きました

→ 現時点ではこの辺りが改善されてる可能性もあるので、ご了承くださいm

offerはtokenだけを渡して、後はよしなにplay consoleがハンドリングしてくれるので一度消費していたら、実際の金額請求に関しては問題ないのですが、offerを提供しているのか? もう提供済みなのか? のLogなどを取りたかったため、この辺りは少し困りました。

→ これに関して、金額も一緒で複数のフェーズに分かれて、全部流れてきてしまい、反映されるまではoffer消費していてもoffer提供価格の金額が流れてくることがありました

じゃあ、バックエンドに頼って、バックエンド側でLogを送ってもらry

→ offer提供かどうかなんて判断できひんで^^

・・・・

mikanではどうしたか

あまり良くないとは思いますが、以下のようにしました

  • 無料トライアルはアプリ単位で1回のみなんで、起動時にplay storeの履歴を確認し、1度でも購入してるかをみる
  • 限定オファーもアプリ単位で1回のみなんで、起動時にplay storeの履歴を確認し、1度でも対象のItem群の内のどれか1つでも購入してるかをみる

この状態で条件を満たさない限りはofferTagを選択させないという状態にすることで消費されたかどうかを実現させています。

また、先程も述べたようにdefaultだと最低価格計算になってしまうため、offerを未適用にさせるという処理も追加する必要がありました。

→ 限定オファーの条件を満たしてないけど、まだ消費してない場合といったビジネス上の条件が存在する場合に当てはまります

実際のoffer選択の処理は以下のようにしています

var selectedOfferDetails: SubscriptionOfferDetails? = null
productDetails.subscriptionOfferDetails.orEmpty().forEach{offerDetails->
if (selectMode != null) {
        offerDetails.offerTags.forEach{tags->
if (tags.contains(selectMode.offerTag)) {
                selectedOfferDetails = offerDetails
            }
}
} else {
        if (offerDetails.offerTags.isEmpty()) selectedOfferDetails = offerDetails
    }

未適用 = 通常価格の場合はofferTagsが空っぽのため、空っぽのofferDetailsをセットして返してあげることで実現できてます!!

この辺りのofferのいい感じの処理の仕方、何かいい方法を知ってる方いれば、よろしくお願いします!!!

スペシャルサンクス

今回、mikanのBilling Libraryをv5にアップデートでき、無事何事もなくスムーズに上位プランであるPRO PLUSを導入できたのも、業務委託で手伝ってくださってるsyarihuさんのおかげです!

課金基盤の準備、アップデート作業、v5の調査や実装過程のアドバイスなどをしてくださってました!改めて、本当にありがとうございます!

twitter.com

おわりに

いかがだったでしょうか? まだ最新版に上げてないよ~って方や、これから使ってみようかなという方の参考になればと思います!

さて、mikanでは最高の英語アプリを共に作っていける仲間を常に探しております!(特にデザイナー…!!)

お気軽にTwitterでも下記のHERP経由でもご連絡ください。主はSplatoonが大好きなのでSplatoon面談もWelcomeです!

herp.careers

明日は@yocchanによる、「なぜ英語を勉強するのか-私が考えるmikanで人生の可能性が広がる理由」です!お楽しみに!

GCPのサービスアカウントを簡単かつ安全に利用するための方法

こちらはmikanのアドベントカレンダー16日目の記事になります。

15日目は @yirszk から プロジェクトオーナー制を突き詰めてチームの成果を最大化した話でした。チーム全体の生産性を高めるためにぜひ参考にしてみてください!

こんにちは。mikanでBackendエンジニアをしております。 @hoshitocat です。

最近は急に寒くなってきましたね。自宅で灯油ストーブを出したのですが、久しぶりの給油で絨毯に灯油をこぼしてしまい、絨毯を自宅のお風呂場で洗うのが大変でした。灯油の扱いには注意したい季節ですね・・・

さて、この記事では、GCPとFirebaseメインでサービスを構成している弊社mikanがどのようにしてサービスアカウントを利用しているかについて紹介します。

サービスアカウントの基本的な利用方法、応用編としてGKEを使う上で本番環境におけるサービスアカウントの利用方法がわかると思います。

はじめに

サービスアカウントとは、アプリケーションからGoogle Cloudのサービスを利用するときに使用するアカウントになります。

Firebaseを使ったことがある方はご存知かと思いますが、以下のようにプロジェクト設定から、サービスアカウントの鍵を発行し、Firebaseの機能をアプリケーションから利用できることが示されています。

⚙️ → プロジェクトの設定 → サービスアカウント

🆖 サービスアカウントの推奨されていない使い方

当初、私はサービスアカウントの鍵を自分で生成し、それをローカル開発環境やステージング(QA)、本番環境で利用していました。

しかし、サービスアカウントキーはできるだけ自分で管理しないことが推奨されています。自分で管理するのは非常に面倒です。不正利用されないために、漏洩対策や漏洩時の被害を最小限にするために鍵のローテーションなどが必要になってきます。この話は サービス アカウント キーを管理するためのベスト プラクティス にも記載されています。

弊社でサービスアカウントキーを利用する際には鍵のローテーションJobを作成し、毎朝10時に鍵をローテーションしGCSへアップロードしていました。

🆗 サービスアカウントの推奨されている使い方

サービス アカウントを操作するためのベスト プラクティスに公式からサービスアカウントの推奨されている使い方が記載されています。今回はこのベストプラクティスに従って、弊社がどのようにサービスアカウントを利用しているのか?具体例を交えながらご紹介していきます。

ローカルの開発環境

いきなりですが、ローカルの開発環境ではサービスアカウントを使用しないことが推奨されています。サービスアカウントではなく、開発者自身のユーザを使用します。そのために、 gcloud CLI が必要になります。

以下を実行すると、ユーザのログインが求められます。

gcloud auth application-default login

ログイン後、以下に認証情報が保存されます。

$HOME/.config/gcloud/application_default_credentials.json

この認証情報はサービスアカウントキーとは別物になるのですが、Cloud SDKなどの公式が出しているライブラリを使っている場合で、ADC(Application Default Credentials)を使った認証であれば区別する必要はありません。

ADCは以下の場所を探索してくれるため、アプリケーションコード上の初期化時には認証情報を明示的に渡す必要はありません。

  1. 環境変数 GOOGLE_APPLICATION_CREDENTIALS
  2. gcloud auth application-default login の任意の場所
  3. 接続されたサービスアカウント

ローカルのホストマシン上は、これで特に2をやっていれば何もやることないのですが、Docker環境の場合はリモートマシン上で、1~3を満たす必要があります。弊社の例は以下になります。環境変数に入れることもできるのですが、結局リモートにマウントすることになるので、この方法にしました。

version: '3'
services:
  app:
    volumes:
      - ~/.config/gcloud/application_default_credentials.json:/root/.config/gcloud/application_default_credentials.json
      # 省略

本番環境

サービスアカウントを使用します。公式の以下の図から何を使えばよいのか?がわかります。図からわかるようにほとんどの場合で、一番右下 Create service account key に該当することがありません。つまり、ユーザ自身がサービスアカウントキーを発行してそれを使う場面がほとんどないということです。

Google側で鍵の管理をしてくれて、安全にサービスにアクセスする仕組みが整えられていてありがたいですね。

「サービス アカウントを使用するタイミングを選択する」から抜粋

弊社では、GKEを使っていますが、最初は default の設定のまま使っていました。

GKEのノードプールにはサービスアカウントが紐づけられます。これは何もしないと、Compute Engineのデフォルトサービスアカウントになります。

こんな感じのやつ 👇

{プロジェクト番号}-compute@developer.gserviceaccount.com

上記は 編集者 という割と結構強めのロールが付与されています。そのため、本来ワークロードから使うことがないAPIも呼び出せてしまう権限を持っています。また、ノードプール中のノードおよびPodに関しても同様のサービスアカウントを使用して、Google Cloud APIが実行できるため、過剰なアクセス権を付与してしまうことになります。

Workload Identityを利用する

じゃあどうすれば良いのか?ということですが、弊社では、推奨されているWorkload Identityを使うようにしました。

基本的には公式の Workload Identity を使用する 方法が示されているので、その通りに実行していくだけです。

弊社では、すでに本番環境で動作中のため、既存のクラスタがある場合についての事例となります。

  1. クラスタWorkload Identity を有効化

     gcloud container clusters update {{ クラスタ名 }} \
         --zone=asia-northeast1-a \
         --workload-pool={{ GCPプロジェクトID }}.svc.id.goog
    
  2. Workload Identity を有効化したノードプールを新規作成

    既存のノードプールを更新することもできますが、現在ワークロードが本番環境で動作している状態で、Workload Identityを有効化すると、動作中のワークロードがCompute Engineのデフォルトのサービスアカウントを使えなくなり、一時的に停止してしまう可能性があるようです。

    そのため、今回は新しいノードプールを追加 → その後古いノードプールを削除するといった手順を取ります。

    これからGKEでインフラを構成しようと考えている方は、新規で作る段階から有効化することをオススメします!

     gcloud container node-pools create {{ ノードプール名 }} \
         --cluster={{ クラスタ名 }} \
         --workload-metadata=GKE_METADATA
    
  3. Kubernetes サービスアカウントを作成

     kubectl create sa {{ Kubernetesサービスアカウント名 }} \
         --namespace {{ ネームスペース(defaultネームスペースでも可) }}
    
  4. 利用するサービスアカウントを作成

     gcloud iam service-accounts create {{ サービスアカウント名 }} \
         --project={{ GCPプロジェクトID }}
    
  5. ④のサービスアカウントに必要なロールを付与

     gcloud projects add-iam-policy-binding {{ GCPプロジェクトID }} \
         --member "serviceAccount:{{ サービスアカウント名 }}@{{ GCPプロジェクトID }}.iam.gserviceaccount.com" \
         --role "{{ 付与したいロール }}"
    
  6. サービスアカウントとKubernetes サービスアカウントを紐づける(アノテーションを付与する)

    saserviceaccount の略なので、 kubectl create serviceaccount とやっても同じです。以降長いので sa の方を使います。

     kubectl annotate sa {{ Kubernetesサービスアカウント名 }} \
         --namespace {{ ネームスペース(defaultネームスペースでも可) }} \
         iam.gke.io/gcp-service-account={{ サービスアカウント名 }}@{{ GCPプロジェクトID }}.iam.gserviceaccount.com
    
  7. ワークロードのマニフェストアノテーション付きのKubernetes サービスアカウントを使用するように変更を加える

    ※このときに、 nodepool の指定が必要な場合は、既存に影響が出ないように nodeSelector に条件を追加( cloud.google.com/gke-nodepool=ノードプール名

     spec:
       template:
         spec:
           serviceAccountName: {{ Kubernetesサービスアカウント名 }}
           nodeSelector:
             iam.gke.io/gke-metadata-server-enabled: "true"
    
  8. 適用する(ここは各環境で、弊社は Kustomize を使っているため、以下のような感じ)

     kubectl apply -k {{ path/to/dir/ }}
    

これで無事にワークロード上で、Google Cloud APIの使用が確認でき、デフォルトサービスアカウントでの認証をしなくても良くなりました。

ちょっとだけセキュアになりました👮(Workload Identityが有効になっているノードプール以外のノードプールを忘れずに削除しましょう 🙆‍♂️)

今回はGKEに関してだけですが、デフォルトサービスアカウントが使われている箇所は他にもあるので、今後そこへの対応やっていきたいと思います。

🙂 まとめ

今回は、サービスアカウントの簡単かつ安全に利用するための基本的な方法と、実際に弊社でどう使っているのかについてご紹介しました。

サービスアカウントは非常に便利で、簡単にGoogle Cloud APIを利用をスタートすることができます。しかし、私がミスしたように、使い方を誤ってしまうと大きなセキュリティリスクにつながることになりますので、気をつけましょう。

今回は、開発環境とGKEについてのみの紹介となってしまいましたが、基本的にサービスアカウントキーを自分で発行して管理しないということが重要になってくるかと思います。少しでも、誰かの参考になれば幸いです。

さいごになりますが、弊社では一緒に働く仲間を募集しています。もし、ご興味のある方は以下のリンクからでも良いですし、私にDMしていただいても良いので、お気軽に声かけてください!

01. バックエンドエンジニア - 株式会社mikan

特にデザイナーは足りていません 😭

ご興味ある方は何卒お声がけください!!社内のデザイナーにもお繋ぎします 🧑‍🎨

04. デザイナー - 株式会社mikan

それでは、また次回もお楽しみに。

mikan Advent Calendar 2022 - Adventar

ExoPlayerとComposeでオーディオプレーヤーを作った際のTips

これはmikanのアドベントカレンダー 10日目の記事です。

9日目は@mizorinからmikanのフルリモート環境を支える工夫でした。 そういえば、Gatherを使ってみようかという話が結構前にあったなーと懐かしい気持ちで当時を振り返りながら、気づけば今では当たり前の習慣になっていてもはや生活の一部となっています。こんな便利な仕組みをどうやって作ったのかという背景が知れるのでぜひぜひこちらもみていってください。

はじめに

こんにちは。 mikanでAndroidエンジニアをしております、zukkeyといいます。

今回は、今年リリースされた機能の一つで、特定の教材のみでmikanで利用できる 「オーディオプレーヤー」 の機能を実装する際に、学び得た知見を書いていこうと思います。

本記事で、対象となる読者は次のとおりです。

対象読者

  • ComposeとExoPlayerを組み合わせた場合のハマったことや実装のちょっとしたTipsについて知りたい方
  • ある程度Composeを触ったことがあるという方

オーディオプレーヤーとは

mikanでは、オーディオ再生が可能な一部教材で使える聞き流し機能のことを「オーディオプレイヤー」と定義しています。

ExoPlayerとは

ExoPlayerとは、動画や音声を扱う際に利用するライブラリです。 ExoPlayerに関しては、詳細な公式ドキュメントが用意されているので、もしわからないことがあればこちらを参照すると良いです。 初めて実装する方はまずコードラボを、サンプルも用意されており、ExoPlayerの使用方法についてはこちらの方が丁寧かと思いますので割愛します。

今回、オーディオプレーヤーを実装するにあたって、Composeと組み合わせた際に4つTipsがあったのでそれらについて紹介させていただこうと思います。

目次

画面を跨いでオーディオを操作したい場合のTips

現在のmikanのオーディオプレーヤー部分の画面は次のとおりです。 トラック一覧画面からプレーヤー詳細画面への動きを載せてみました。

今回機能を実装する際に、Composeで作ってはいるものの、一部Composeに置き換えることができないという状態でした。

現状、mikanのアプリのトップ画面はComposeを使わない実装のままなので、プレーヤー詳細画面を開く時には、別途BottomSheetDialogFragmentを利用しなくてはなりませんでした。

プレーヤー詳細画面のBottomSheetDialogFragment内部では、Composeを利用しています。

この実装の場合だと、画面を跨いで再生位置、再生状態、どのトラックを選択しているかを保持する必要があります。

そのため、mikanではactivityViewModelsとして、オーディオ状態を管理するViewModelを用意するようにしています。

一部でかつ画面を跨いで状態を保存したい時は、activityViewModelsで状態を管理すると簡潔に実装できます。

トラック再生の落とし穴にハマらないためのTips

今回のオーディオプレーヤーを実装する際に、要件として、3秒早送りと巻き戻し、トラックの切り替え部分がありました。

実際にmikanアプリの挙動は次のとおりです。

この挙動を実装する際に、ExoPlayer側で用意されているseekTo関数を利用すると実現できます。

今考えると初歩的なミスではあるのですが、トラックの切り替え時にseekTo(windowIndex, positionMs)を利用している際にハマってしまっていたことがありました。

トラックはトラックに関する情報を持っていて、その中にTrackNumberがあるので、最初のうちはTrackNumberでseekTo(trackNumber, 0)のように呼び出すようにしていました。

しかし、教材によっては構造が異なることから次のようなパターンになっています。

  • 教材
    • アルバム1
      • トラック
    • アルバム2
      • トラック
    • アルバム3
      • トラック

mikanアプリでは、この構造の場合にTrackNumberは必ずしも同じではなく0 ~ 10、11 ~ 21のように変わります。

ExoPlayerにセットしている時には、アルバムそれぞれ全てに設定するのではなくトラックごとにセットしているので、アルバム1では0 ~ 11でたまたまindexがあっていたとしても、アルバム2では11 ~ 21のようになるので、TrackNumberでトラック切り替えを行おうとするとズレが出てきてIllegalSeekPositionExceptionが起きてしまいます。

そのため、seekTo(windowIndex, positionMs)を使う際には、必ずExoPlayerにセットしたリストのindexと合うようにしましょう。

この画面ではオーディオ処理を止めたいのだけど、Composeではどうやったらいいのと困った時のTips

mikanアプリでは、トラック一覧画面では再生してていいけど、アルバム一覧画面では音声を止めて欲しい、という要件がありました。

実際のアプリの挙動はこちらです。これは、トラック一覧画面からアルバム一覧画面へと戻る時の挙動です。

実はこの2画面、Composeで作っています。1つのFragmentにてComposeのスクリーンを切り替えているという感じです。

Composeでの遷移周りは、NavHostとrememberNavControllerで実装すると思います。

特定の画面で、挙動を変えたいということがユースケースとして出てくるのではないかと思います。mikanアプリでもこの音声を止めてほしいという要件がそうでした。

こういった場合に使うのが、addOnDestinationChangedListenerです。これは、rememberNavControllerの現在の表示しているスクリーンがどこであるかを検知できます。

実際にコードで書くと次のとおりです。

val navController = rememberNavController().apply {
    if (currentDestination?.route == AudioScreenType.ALBUM_SCREEN.routeName) {
        // 一番最初の初期状態の時に処理を変えたい場合はこちらに書く
    }
}
navController.addOnDestinationChangedListener { _, destination, _ ->
    if (destination.route == AudioScreenType.ALBUM_SCREEN.routeName) {
        // アルバム一覧画面へきた時の処理を書く
        // ここでExoPlayerの操作をしてしまうのがおすすめ
    }
}

destination.routeは、NavHostにてNavGraphBuilder.composableで指定したroute名と同じですので、単純にdestination.routeと同じであるかどうかの分岐を書くだけでよいです。

ただ、こちら注意点としましては、切り替えるたびに実行されることになりますので、適宜要件に応じてフラグを使うなり、ロジックを持っている側で弾いて早期リターンするなどが必要になってきます。

YouTubeっぽいボトムプレーヤーを作りたい場合のTips

YouTubeのアプリを再生して、バックキーなどの戻る操作をすると、下にミニプレーヤーが表示されると思います。

mikanでは、「ボトムプレーヤー」と呼んでいて、今回この挙動を実現して欲しいという要件がありました。

この挙動、実はComposeでswipeable修飾子を使うことで簡単に作れます。

実際にmikanで実装してみたパターンはこちらになります。

公式ドキュメントにも記載されてある通り、swipeable修飾子を利用することで「スワイプして閉じる」パターンを実装することができます。

swipeable修飾子は、rememberSwipeableStateを用いて状態を作成し、記憶することができます。

この挙動を実装するための全体のコードはこちらです。

val swipeableState = rememberSwipeableState(initialValue = States.EXPANDED, confirmStateChange = { state ->
    if (state == States.COLLAPSED) {
        // ボトムプレーヤーを閉じた時の処理を書く
    }
    true
})

BoxWithConstraints(
        modifier = Modifier
            .fillMaxWidth()
            .align(Alignment.BottomCenter)
    ) {
        val constraintsScope = this
        val maxHeight = with(LocalDensity.current) {
            constraintsScope.maxHeight.toPx()
        }
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .swipeable(
                    orientation = Orientation.Vertical,
                    state = swipeableState,
                    thresholds = { _, _ -> FractionalThreshold(0.5f) },
                    anchors = mapOf(
                        0f to States.EXPANDED,
                        maxHeight to States.COLLAPSED,
                    )
                )
                .offset {
                    IntOffset(
                        x = 0,
                        y = if (swipeableState.offset.value.roundToInt() < 0) 0 else swipeableState.offset.value.roundToInt()
                    )
                }
                .clickable(
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null
                ) {
                    // タップした際の処理
                }
        ) {
            // ボトムプレーヤーのViewのComposableをここに入れる。今回は割愛。
        }
}

ポイントとなる点は、2つあります。

1つ目は、スワイプの状態を定義するのに、rememberSwipeableStateでスワイプの状態を作成し、状態とアンカーのペアを設定する必要があります。

まず、BoxWithConstraintsを利用して、ボトムプレーヤー自体のViewの最大の高さを取得します。次のコードの部分になります。

BoxWithConstraints(
        modifier = Modifier
            .fillMaxWidth()
            .align(Alignment.BottomCenter)
    ) {
        val constraintsScope = this
        val maxHeight = with(LocalDensity.current) {
            constraintsScope.maxHeight.toPx()
        }
}

次に、アンカーとswipeableの状態をペアに設定します。

ボトムプレーヤーのViewをBoxで囲い、swipeable修飾子で、anchorsにて初期位置を0と拡大を示すスワイプ状態(Expanded)と、最大の高さつまり下に移動した際にボトムプレーヤーの高さ分動いて欲しいので最大の高さを下に動かした時のスワイプ状態(COLLAPSED)をペアにして設定します。

今回は縦方向に動いて欲しいので、orientationでは、Orientation.Verticalを設定します。

thresholdsは状態がどこで変わるかの閾値を設定します。今回は半分で良いということだったので、0.5fを指定しました。

こうすることで、rememberSwipeableStateが記憶している縦方向の値をもとにスワイプの状態が紐づきました。

BoxWithConstraints(
        // 省略
    ) {
        // 省略
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .swipeable(
                    orientation = Orientation.Vertical,
                    state = swipeableState,
                    thresholds = { _, _ -> FractionalThreshold(0.5f) },
                    anchors = mapOf(
                        0f to States.EXPANDED,
                        maxHeight to States.COLLAPSED,
                    )
                )
            // 省略
        ) {
            // ボトムプレーヤーのViewのComposableをここに入れる。今回は割愛。
        }
}

2つ目は、offset修飾子で要素となるViewの位置を設定し、制限をかけることです。

今回は、縦方向つまり、yを可変な値に設定することで縦スワイプの移動を実現できます。しかし、1点だけ注意点があります。

次のように書いてしまった場合は、挙動が若干異なってしまいます。

BoxWithConstraints(
        // 省略
    ) {
        // 省略
        Box(
            modifier = Modifier  // わかりやすいように一部省略
                .offset {
                    IntOffset(
                        x = 0,
                        y = swipeableState.offset.value.roundToInt()
                    )
                } 
        // 省略
        ) {
            // ボトムプレーヤーのViewのComposableをここに入れる。今回は割愛。
        }
}

一瞬、うまくいったように見えるのですが、この場合だと上方向の制限がかかっていないので、上に引っ張ると隙間が開いてしまうという不具合が出てしまいます。

そのため、正確には次のように上方向、つまり0より小さい場合は初期位置から移動しないように0を指定して書くことで、期待しているスワイプの挙動を実現することができます。

BoxWithConstraints(
        // 省略
    ) {
        // 省略
        Box(
            modifier = Modifier  // わかりやすいように一部省略
                .offset {
                    IntOffset(
                        x = 0,
                        y = if (swipeableState.offset.value.roundToInt() < 0) 0 else swipeableState.offset.value.roundToInt()
                    )
                } 
        // 省略
        ) {
            // ボトムプレーヤーのViewのComposableをここに入れる。今回は割愛。
        }
}

おわりに

今回は、オーディオプレーヤーをExoPlayerとComposeで作った際に学んだTipsの紹介でした。

オーディオプレーヤーは、音声を繰り返し聞き流しているだけで頭に残ってくるので、覚えられるいい機能なのではないかと思います。音声を聞いて自分でも発音すると覚えるスピードが上がりそうです。ただ、私の場合は、実装するときのハマってつらい記憶も思い出しそうというのが、難点です。

同じようにして、プレーヤーを作ろうとしている方の参考になれば幸いです。

さいごに、mikanでは、一緒に英語学習をより良いものにしていくべくサービスを作っていく仲間を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください!他の職種の方もぜひぜひ、ご応募ください〜〜

03. Android エンジニア

現在デザイナーを特に募集中です!ぜひぜひご応募いただけますと嬉しいです〜〜!

04. デザイナー