fresh digitable

めんどくさかったなってことを振り返ったり振り返らなかったりするための記録

Jetpack ComposeでYouTubeのライブ配信リストを作っている

手短に

github.com

動機付け

  • vtuberの配信をよく見るようになった。調子に乗ってチャンネル登録してたら登録数が200をこえました。ななしいんく箱推しです。
  • YouTubeの「登録チャンネル」ページ(PC)は、配信中のものが上の方に出てきて、それ以降は配信ページの公開順に並ぶ。動画やShortsやもちろん配信終了したアーカイブも混ざっている。開始して間もない配信は配信中グループの末尾に追加されるので、ゴールデンタイムには2スクロールぐらいしないと見たい配信にたどり着けない。
  • プレミアムじゃないので通知が来てから配信ページに行ってもすぐ見れない。だから開始前に開いておきたいんだけど「登録チャンネル」から探そうとしてもだいたい見つけられない。
  • これからの時間の配信予定をみて自分の予定を立てたい。
  • 「今やってる配信」や「これからの時間の配信予定」がすぐにわかる何かが欲しかったので、勉強のついでにJetpack ComposeでAndroidアプリをつくることにした。

YouTube Data APIの動きを見よう

  • YouTube Data APIの動きを見たかったので Android Quickstart  |  YouTube Data API  |  Google for Developers を見ながらセットアップしてアプリから呼んでみる。
  • 今回はG様謹製のライブラリを使うので、OAuth2の対応にはDeveloper Consoleの設定(↑のセットアップでやる)が必要だがアプリ側ではruntime permissionで連絡先取得の許可がとれればよい。エラーからの復帰をユーザーに促す処理でDialogFragmentを表示するものなどComposableを完全にフォローしていない部分があり、このあと頭を悩ませることになる。
  • UIはこれまでの方法(XMLのやつ)で一旦簡単に作った。マイグレーションを経験する的な側面もあるかもしれない。
  • 配信中、配信予定のものだけを取得するAPIが無い。紆余曲折を経て、最終的な実装では次のようにして配信リストを作成することにした。
    1. Subscriptionエンドポイントから登録しているチャンネルのリストを取得して、各チャンネル情報から、すべての動画(と配信予定)が含まれるプレイリストのIDを取り出す*1
    2. PlaylistItemsエンドポイントからプレイリストアイテムのリストを取得して、各プレイリストアイテムから動画IDを取り出す*2
    3. Videosエンドポイントに動画IDのリストを渡して動画のデータを取得する。ライブ配信は配信開始時間の情報が含まれているので、これを見て配信/動画とか配信中/配信予定とかを仕分けてリストにする。
  • あとは関係ありそうなAPIの動きをチェックしたかったので動画詳細とかチャンネル詳細の画面を作っておく。
  • 以下、試行錯誤の雑多なまとめ。
    • 当初はActionsエンドポイントから動画アップロード(配信ならいわゆる待機所の作成)のイベントをとってきていた。Actionsエンドポイントにはbefore/sinceを指定できるクエリがあるので、キャッシュに残っている最新のイベントの日付より新しいものをリクエストするといういかにもモバイルアプリ的な実装ができたと思ってご満悦だったのだが、動画の中には非公開でアップロードされるものもあり、その後に公開状態でアップロードしたイベントが来てしまうと非公開の動画アップロードイベントを取得できなくなってしまう。事前に非公開でアップロードされる配信というのがよりによってコラボ配信だったり記念日配信だったりして、取りこぼしたくないので却下した。
    • ChannelSectionsといういかにもWeb版のチャンネルのホームタブのページを作れそうなエンドポイントがあるが、いまのところ「ライブ配信中」セクションも「今後のライブ配信」セクションも空の配列が返ってくる。
    • Searchエンドポイントなら配信予定の枠だけ取得できるが、1回のリクエストでQuotaを100使ってしまい(他のは1)、すぐ上限(10,000/day)に到達してしまうので今回の用途には合わなさそうだった。

Twitch APIの動きを見よう

  • YouTubeでもTwitchでも配信するという人が増えたのでこちらもちゃんとカバーすることにした。実際の作業はComposableに移行した後にやった。
  • とりあえず Get Started | Twitch Developers から始める。
  • OAuth2はWebブラウザに飛ばしてログインしてもらってまたアプリに帰ってくるパターン。launchMode=SingleTopでもSingleTaskでもActivity#onCreate()がどうしても呼ばれてしまうのでよくわからん実装になった。
  • Twitch APIにはライブ配信中のリストやチャンネルの配信予定のリストを取得できるAPIがあるので最終的なリストを作るのは簡単だった。
    • 配信予定は、例えば「毎週月水金の19:00~」みたいに設定できるらしいのだが、配信予定をリクエストすると日付のリストで直近の日付から指定した件数だけ返ってくる。期間の指定ができないので際限なく取得できてしまう(1敗)。
    • フォローとかサブスクライブとか、YouTube側との用語の差異があってクラス名とかをどうすべきかまあまあ悩んだ。
  • APIリファレンス内で用語が統一されていないように見受けられるので文章やcurlの例をよく読む必要がある。
  • twitch4jといういかにもなライブラリがあって使ってみたのだが、Androidアプリであまり使われない依存関係がいくつか含まれており案の定動かすのが面倒くさそうだったので今回は採用しなかった。

Jetpack Composeに移行しよう

  • 画面は配信中と予定タブに分かれていて、それぞれ動画リストを持っている。リストから詳細画面に飛べる。ほかには登録チャンネル一覧とかアカウントの連携画面があって、そこに飛ぶためのNavigation Drawerがある。
  • まずはシンプルにXMLリソース(View, Navigation)をComposableに置き換えた。そのあと、できるかやってみようと思ってActivityの中身もComposableに押し込んだりした。
    • 何回か無限コンポーズ編に突入したりもしたけどStateをProviderで包んで渡すやつをやったら何とかなった。
    • ViewModelとかNavigationControllerをComposableにじかに渡してたけどこれもStateのProviderとかCallbackとかで包んで渡したらなんかすっきりした。
    • YouTubeのOAuth周りの実装をComposableに移してくるのが難しかった。もっとうまくやる方法がありそう。
  • カスタムViewの実装とかActivity/Fragmentのライフサイクルのこととかを考えなくてもよくなったのはうれしい。
  • Modifierにapplyでclickableを足そうとしたらできなかった(applyの中でif文で足すかどうか決めたかった)。本当はenabledを使うところだし今となってはなぜそんなことをしたかったのかも思い出せないけど、不思議な体験をしたことだけは覚えている。
  • Navigationのビューワーなくなって地味にしょんぼり。
  • NavigationのURIむずかしい。
    • PathパラメータならNonNull、QueryパラメータならNullableというようにしたかったが、NonNull/Nullableは型によって予め決められているようだった。
    • startDestinationが引数を受けられないの不便かなって思う。初期値を使って乗り切るみたいな方法がよく紹介されているけど値がNullableでなければならないので実質的にString一択になるんじゃないか。引数を受けたい画面はナビゲーションツリーの根になれない。
    • 引数名とかリファレンスの中のrouteっていうのがuser/{userId}のことなのかuser/1234のことなのかわからんことある。
    • パスやパラメータの名前を定数で何度も書かなきゃいけないのがだるいしtypoするかもなと思ったので結局オレオレフレームワークみたいなものを作った。面倒くささと引き換えに型で守られている安心感のようなものを得られて満足している。
  • AppBarのハンバーガーアイコンとUpボタンの切り替わりの便利なクラスとかアニメーションないなった。
  • rememberどこで使ったらいいかいまいちわからない。

まとめとか雑多な感想とか

  • YouTubeは動画配信プラットフォーム、Twitchはライブストリーミングプラットフォーム。
  • 最初はYouTubeだけだったので公式が便利になったらこのアプリは必要なくなるんだけど、Twitchと合わせることで情報を一か所に集めることができるからあらためて作る意味ができてよかった。
  • YouTubeの方は今の方法でも取得できない配信があるので要調査。サムネの更新も取れたり取れなかったりする。
  • Twitchの方はスケジュールと配信との対応のつけ方がわからないので要調査。
  • フリーチャットを別のタブに分けられたらいいな。どうやって分けるかが課題。今の実装では枠が作成された時期によっては取得できないのもなやみ。
  • マルチアカウント対応って必要?(YouTubeにログインするアカウントが複数あってそれぞれで登録してる人が異なるケース)
  • あらかじめわかっているチャンネルIDとかをアプリで持ってればSubscriptionsのリスト取得が必要ないし箱公式のアプリみたいなものが作れるかも?なおQuota上限*3
  • PCで見ることの方が多いので本音を言うとWebでやりたい*4GDCの設定が別途必要になるけどfor Webもやってみればいいだろうか。Androidアプリもそれはそれで需要ある。
  • GDCのセットアップとかソースコードのビルドとかができる人は自己責任で使ってみてください(アプリのパッケージIDを変えなきゃいけないかも?)。環境構築のサポートはできませんがソースコードの問題はissueやpull reqにて受け付けます。

*1:卒業をされるとこのプレイリストが非公開になる様子。こんな形で知りたくなかった…

*2:プレイリストには別のチャンネル(例えばサブチャンネルのような)に上がっている動画も含まれていることがあるので、ローカルキャッシュのDBでここら辺のIDに対して参照制約をつける場合は配慮が必要

*3:今の実装だと私一人で3,000/dayぐらい使っている。減らしたいけど登録数に比例するのでどうかなって感じ。

*4:Chrome Extensionとかの方が簡単に早くできたりしますか?

Androidの通知で音を鳴らすときに注意したいこと

こんなことで困る人なんていないような気もするが、ネタとしては面白いと思ったのでまとめてみる。困っていることというのは、res/raw以下にある複数のファイルのうち任意の1つを消すと通知の着信音が変わってしまうということ。何を言っているか分からないと思うのでどうしたらそんなことになるのか一つずつ説明していく。

Androidの通知について

本題に入る前に通知の基本的なことを抑えておく。

developer.android.com

  • 通知は通知チャンネルを通してユーザーに届けられる。
  • 通知チャンネルはアプリが生成する。このとき、通知の着信音や振動、ライトのパターンなどを初期設定として渡せる。
  • 通知チャンネルは通知音や振動パターンの設定をユーザーが変えられる。
  • 通知チャンネルの設定はアプリから変更できない。
  • 通知チャンネルはプログラムで削除できるが、削除したことはアプリの設定画面に表示される。また、設定をリセットしたいと考えて一旦削除し、再び同じIDのチャンネルを作ろうとしたとしても、削除する前の通知チャンネルが復元されるだけで設定をリセットすることはできない。

ポイントは、通知チャンネルはアプリで生成した後はすべてユーザーの手にゆだねられるということで、ユーザーがOffにした通知をアプリから勝手にOnにするようなことはできない*1。また、間違って設定してしまってもアプリの側から修正することはできない。状態を是正したければアプリの設定からユーザーに依頼してデータを消してもらうしかない(アプリ情報>ストレージとキャッシュ>ストレージを消去)。

通知の着信音

通知チャンネルに通知が届いた際に、設定した着信音を鳴らすことができる*2。この音は次のAPIで、チャンネルを生成する際にファイルのURIを渡すことで指定できる: https://developer.android.com/reference/android/app/NotificationChannel#setSound(android.net.Uri,%20android.media.AudioAttributes)。また、ユーザーがアプリ設定の画面から音を設定することもできる。

このAPIはおそらくコンテンツプロバイダ経由でアクセスできるファイルのURIが渡されることを想定しているのではないかと思うのだが、実際にはアプリで抱えているファイルもファイルパスをURIで表現して渡せる。例えば、res/rawの下のファイルは次のようなURIで表現できるが、これを渡すことでアプリ独自の通知音を再生できるようになる。

android.resouce://<application ID>/raw/<resource name>

あるいは、

android.resource://<application ID>/<resource ID>

参考: stackoverflow.com

今回の問題の原因と対策

今回の敗因はリソースIDを使う方のURIでファイルを指定していたこと。リソースIDはビルドするたびに変わる可能性がある。今回は不要になったファイルを一つ消してビルドしたところ、そのファイルよりもアルファベット順で後にあるファイルのリソースIDが一つずつずれて、通知音が軒並み変わってしまった*3。この現象には開発中に気が付いたのでリリースされることはなかったが、やろうと思っていたことが一つできなくなってしまった。前述の通り、アプリから通知の設定を変えることはできないので、アプリ更新時にマイグレーションをすることはできない。

こんなことが起きるのは私の環境だけだろうか。ビルドするときにリソースIDを極力変えないみたいなフラグがあったりするのだろうか。デフォルトでそうなっていてほしいという気もする。ファイルを追加するときはリソースIDが変わらないのも不思議に思っている。

Androidの通知で音を鳴らしたいときは、リソースIDを使うURIで指定するのは避ける。また、できれば音ファイルをコンテンツプロバイダに登録して、そのURIを指定した方がよい。そうしておくと、ユーザーが誤って着信音の設定を変えてしまっても、ユーザー自身で元の着信音に戻すことができる。

*1:Offになっているという事はわかるので、ユーザーに「Onにしてね」といって促すことはできる

*2:通知に音を設定するのではなく、通知チャンネルに設定されている音が鳴る

*3:通知チャンネルを山ほど作るアプリを作っているわけです

effective dartを一言ぐらいでまとめていく(Usage編)

続きです:akihito104.hatenablog.com

Usage

Library

複数のファイルに分かれたプログラムを、一貫性のあるメンテナンス可能な方法で構成するのに役立つ。importについてのみ言及しているが、exportディレクティブに対しても応用できる。

  • part ofディレクティブではライブラリ名ではなくURIの文字列を使う(part自体がもうほとんど使われなくなっちゃったけど)。
  • ほかのパッケージのsrcディレクトリの下のファイルをimportしてはいけない。lib/src以下のファイルはパッケージの実装という仕様になっている。
  • libディレクトリの外から相対パスURIを使ってlib以下のファイルをimportしてはいけない。そのような場合はpackage:でimportする。
  • lib内のファイルをimportする(libをまたがない)場合は相対パスを使うのが好ましい。

Null

  • 変数を明示的にnullで初期化してはいけない。Dartにはuninitialized memoryという概念は無く、暗にnullで初期化される。
  • デフォルト値に明にnullを指定しない。これも暗にnullで初期化される。
  • bool?boolに変換する際は??を使うのが好ましい。ただし、ifの条件式の中でnull-awareness演算子を使ってbool?boolにするのは避ける。そのような場合は例えば list != null && list.isEmptyのようにnullチェックをする。
  • 初期化されたかどうかをチェックしたい変数に対してlateを使うのは避ける。Dartではlateの変数が初期化されたかをチェックする方法は提供していない。lateの変数が初期化されたかどうかをboolのフラグによって管理するのは冗長(Dartは内部的にそのように管理している)なので、シンプルにnullableな変数として定義し、nullであるかどうかをチェックする。
  • 型の上位変換を可能にするために、nullableなフィールド変数をローカル変数にコピーすることを検討する。上位変換はローカル変数に対してのみ有効であるため。ただし、コピーした後で値をフィールド変数の方に書き戻す必要がある場合は、忘れずに書き戻すこと。また、フィールドの方の値がスコープの外で更新される可能性がある場合は、単純にフィールド変数を直接使う(!を使って値を取り出す)ことを検討する。

Strings

  • 文字列リテラルを連結するときにはadjusent strings(隣接文字列?)を使う。+を使う必要はない。長い文章を折り返すのに便利。
  • 文字列と値とを組み合わせるときは内挿を使う。+で連結することもできるけど、内装の方がきれいで手短に済むので好ましい。
  • 内挿を使う時に不要な波カッコを使わないようにする。

Collections

Dartでサポートされているリスト、マップ、キュー、セットについての話題。

  • 可能な時はコレクションリテラルを使う。MapとSetには無名コンストラクタがあるが、便利なビルトインの構文を使った方がよい。
    • 名前付きコンストラクタを使う場合はこの限りではない
    • spread演算子for文と組み合わせてコレクションを生成することもできる
  • コレクションが空であるかチェックするときにlengthを使わない。そもそもIterableは長さが分からない。かわりにisEmptyisNotEmptyを使う。
  • Iterable.forEach()は関数リテラルとともに使わないようにする。ただし、すでにある関数を渡すのは良い(names.forEach(print)のような)。また、MapIterableではないのでforEach()を使ってよい。
  • 型を変えたいときだけList.from()を使う。単にコピーしたい場合にはtoList()を使う。
  • Listのフィルターを型で行いたい場合はwhereType()を使う。
  • それに近い処理ができる場合にはcast()は使わない。
    • toList().cast()List.from()で置き換えられる
    • map().cast()map<T>()で置き換えられる
  • cast()を避ける。前述のルールをやや一般化する。cast()を使うのが正解であることもあるが、このメソッドがリスキーで思わぬ処理の遅延やケアレスによる実行時の失敗を引き起こすということを考慮しておく。
    • 最初から正しい型を指定する
    • 値を取り出すときにキャストする
    • List.from()を使って即時に変換する。cast()は遅延評価のコレクションを返すので、オペレーションごとに型のチェックを行う。要素も処理も少ない場合には有効だが、多くの場合この遅延評価のオーバーヘッドが重くなる。

Functions

Dartでは関数もオブジェクト。

  • 名前付きの関数を定義するときは関数と名前をバインドする。final func = () => ...ではなく、void func() { ... }のような。
  • tear-offができるときはクロージャを作らない。tear-offとは関数、メソッド、名前付きコンストラクタと同じ引数をとり、呼び出そうとした関数と同じ関数を呼ぶクロージャのこと。Dartではこのtear-offを自動で作るので、わざわざクロージャを書いてラップする必要はない。
  • 名前付き引数にデフォルト値を与えるときは=を使う。過去の経緯から:も使うことができるが、一貫性の観点から=を使うのが望ましい。

Variables

  • 一貫性のあるルールに従ってローカル変数にvarfinalを付けること。ローカル変数には型アノテーションは付けず、必ずvarfinalで定義する。次の2つのルールのいずれかが広く使われている。
    • 再代入されないものにはfinalを使い、それ以外にはvarを使う。
    • 再代入されないものであってもvarを使い、finalは一切使わない(フィールド変数やトップレベル変数についてはこの限りではない)。
  • 計算可能な値を保存しないようにする。パフォーマンスの観点でキャッシュする場合にはコメントを付けておくとよい。

Members

Dartではオブジェクトは関数(メソッド)やデータ(インスタンス変数)といったメンバーをもつ。

  • 不必要なgetterやsetterでラップしない。
  • 読み取り専用フィールドはfinalを付けるのが望ましい。
  • シンプルなメンバでは=>の使用を検討する。ただし、2行以上になるものや条件文が複雑なものはブロック形式で記述する。値を返さないようなsetterについても=>を使える。同じく=>を使っているgetterのメンバと対応させるとわかりやすい。
  • 名前付きコンストラクタにリダイレクトしたりシャドウイングを避ける以外の用途でthisを使わない。
  • 可能な場合は宣言時にフィールドを初期化する。

Constructors

  • 可能な場合はinitializing formalsを使う。できない時は仕方ないけどできるときはそうすべき。
  • コンストラクタのリストイニシャライザを使う時はlateを使わない。
  • コンストラクタのボディが空の時は {}ではなく ;を使う。
  • newを使わない。
  • 無駄にconstを使わない。次のようなケースは暗黙的にconstなので書く必要はないし、無駄なので書くべきではない(ただし、デフォルト値については後続のリリースでconstでないものにも対応するのでこのリストには入れていない)。

Error handling

  • on節を使わずにcatchするのは避ける。on節を付けないとtryブロック内で起きたすべての例外をキャッチすることになる。StackOverflowErrorOutOfMemoryErrorを適切に処理するのは難しいし、意図的に出しているArgumentErrorassert()の例外を握りつぶすのは本意ではないはず。catchon節をつけて、自分が認識していて正しく処理できる例外だけをキャッチする。まれに、すべての実行時例外をキャッチしたいという事があるが、そういうものは普通はフレームワークや低レイヤにおいて、それらのコードが原因でプログラムに問題が起きるのを防ぐのに使われる。こうした場合でもすべての例外をキャッチするよりもExceptionだけをキャッチする方がよい。Exceptionはすべての実行時例外の基底クラスである。
  • on節なしでキャッチしたエラーを捨ててはいけない。もしすべてのエラーをキャッチした方がいいと考えるなら、キャッチしたときにログをとったり、ユーザーに知らせたりするべきで、ただ静かに捨てていいものではない。
  • プログラムのエラーのために実装したErrorを投げる。Errorクラスはプログラムのエラーの基底クラス。Exceptionは実行時例外の基底クラス。Exceptionを投げるべきところでErrorを投げるとミスリーディングになるので気を付ける。
  • Errorやその実装型をキャッチしない。Errorはプログラミングのミスなので、プログラムを停止させてスタックトレースを表示し、ミスの原因に立ち返ってこれを修正する。
  • キャッチした例外を投げなおすときはrethrowを使う。rethrowを使うと元のスタックトレースをそのまま保存できるが、throwで投げてしまうと最後に投げた地点でこれがリセットされてしまう。

Asynchrony

  • Futureをそのまま使うよりasync/awaitを使うのが好ましい。非同期のコードはFutureを使ったとしても読んだりデバッグするのは難しいが、async/await構文を使うことで読みやすくできる。
  • 無駄なところでasyncを使わない。便利なのは次のような場合:awaitを使う時(これは自明)、Errorを非同期に返したい時(Future.error()より短く書ける)、値をFutureでラップして返したい時(Future.value()より短く書ける)。
  • ストリームを変換するために高階関数を使うことを検討する。(Iterableに関する上記の議論って何?)
  • Completerを直接使うのは避ける。普通はFutureasync/awaitで事足りる。Completerは低レイヤのコードにおける次の2つの場合に必要である:非同期のプリミティブを新しく作る時、Futureを使わない非同期のコードのインタフェースに使う時。
  • ObjectであるかもしれないFutureOr<T>の型を解決する際には、まずFuture<T>であるかどうかをチェックする。もしTintであるなら、FutureOr<int> valuevalue is intvalue is Future<int>で見分けられる。しかし、TObjectである場合、Future<Object>もまたObjectであるのでintの場合のように見分けることができない。