本文へスキップ
すべての記事

Androidのアプリショートカットとクイック設定タイル:アプリを開かずに水分補給を記録する

AndroidのダイナミックShortcutManager APIとTileServiceの実践ガイド — ワンタップの操作でアプリ起動を完全にスキップする方法と、多くの実装が陥りがちな落とし穴について。

MFKAPPS 1 分で読めます

どんなアプリの操作でも、最速のバージョンはアプリを一切開かないものだ。Hydrameの中核ループ — コップ一杯の水を記録する — は1日に何十回も発生する。それなのに、スマホのロックを解除し、アイコンを探し、コールドスタートを待ち、ボタンをタップしてもらうというのは、本来1ステップであるべきものに対して3ステップも多い。Androidにはこれを1タップに圧縮する方法が2つある — ホーム画面のアプリショートカットと、クイック設定タイルだ。両者は異なる入り口から同じ問題を解決していて、どちらも見た目より実装は小さい。それぞれが実際にどう動くのか見ていこう。

2つの入り口、2つの異なる役割

アプリショートカットはランチャーアイコンの下に存在する — 長押しすると、あなたが定義したアクションのメニューがポップアップする。これはこの特定のアプリに紐づいた、少数の高頻度アクション向けで、すでにアイコンを見ている誰にでも発見できる。

クイック設定タイルは通知シェード内、Wi-FiやBluetoothの隣に存在する。これは、アイコンを探し回ることなくどこからでも使えてほしい1つのアクション向けだ — アプリの機能というより、ハードウェアのボタンに近い。

Hydrameは両方を実装している — ランチャーアイコンの下に「Log 250ml」と「Log 500ml」のショートカット、そしてロック画面のシェードから1タップでデフォルトの量を記録するクイック設定タイルだ。これらは別々のAPIであり、別々の登録経路を持つので、無理に統一しようとせず別々に作る価値がある。

ダイナミックショートカット:ShortcutManagerCompat

ショートカットには2つの種類がある — 静的(XMLで宣言され、ビルド時に固定される)とダイナミック(実行時にプッシュされ、アプリの状態に応じて変わりうる)だ。固定の「Log 250ml」は変わる必要がないので静的でも動作するが、ダイナミックショートカットなら、量をその人が実際に飲んでいる量に反映させられる — アプリが推測した2つのサイズではなく、その人が最もよく使う2つのサイズに。

val shortcut = ShortcutInfoCompat.Builder(context, "log_250ml")
    .setShortLabel("250ml")
    .setLongLabel("Log 250ml of water")
    .setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_glass))
    .setIntent(
        Intent(context, LogIntakeReceiverActivity::class.java).apply {
            action = ACTION_LOG_INTAKE
            putExtra(EXTRA_AMOUNT_ML, 250)
        }
    )
    .build()

ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcut250, shortcut500))

intentはBroadcastReceiverではなくActivityをターゲットにしなければならない — これがショートカットAPIが課す唯一の厳格な制約であり、レシーバーが正解となる通知アクションのパターンとは異なる。Google自身のガイダンスは、そのActivityをできる限り目に見えないものにすることだ — レイアウトなし、書き込み直後にfinish()

class LogIntakeReceiverActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val amount = intent.getIntExtra(EXTRA_AMOUNT_ML, 0)

        lifecycleScope.launch {
            intakeDao.logIntake(amount, timestamp = System.currentTimeMillis())
            Toast.makeText(this@LogIntakeReceiverActivity, "Logged ${amount}ml", Toast.LENGTH_SHORT).show()
            finish()
        }
    }
}

透明なテーマ(windowIsTranslucentでオーバーライドしたTheme.Material3.DayNight.NoActionBar)を与えれば、その遷移はアプリの起動ではなくシステムの確認のように見える — ミドルレンジの端末でも、全体が0.5秒未満で完了する。

順位と上限:ショートカットはメニューではない

ShortcutManagerCompat.setDynamicShortcuts()は呼び出すたびにセット全体を置き換え、システムは件数に上限を設けている — getMaxShortcutCountPerActivity() — ランチャーにもよるが、通常は4〜5個程度だ。それを超えてプッシュすると、余分な分はキューに入るのではなく黙って破棄される。順序も重要だ — ショートカットは渡した順に描画されるので、その人が最もよく記録する量は最後ではなく最初に置くべきだ。

もう一つの罠は、setDynamicShortcuts()を頻繁に呼びすぎることだ。各呼び出しは、アプリを起動するたびにではなく、数週間かけて変化するユーザーの習慣のような、本物の状態変化のためのものだ。コールドスタートのたびに同じ2つのショートカットを書き直しても、システムコールのコストがかかるだけで何も得られない — プッシュする前に、セットが実際に変わったかどうかを確認すること。

クイック設定タイル:TileService

タイルはActivityではなくサービスだ — ライフサイクルはAndroidが制御し、重要なエントリーポイントはonClick()ただ1つだけだ。

class LogWaterTileService : TileService() {
    override fun onStartListening() {
        super.onStartListening()
        qsTile?.apply {
            label = "Log water"
            icon = Icon.createWithResource(this@LogWaterTileService, R.drawable.ic_tile_glass)
            state = Tile.STATE_ACTIVE
            updateTile()
        }
    }

    override fun onClick() {
        super.onClick()
        val pendingResult = goAsync()
        lifecycleScope.launch {
            try {
                intakeDao.logIntake(defaultAmountMl(), timestamp = System.currentTimeMillis())
            } finally {
                pendingResult.finish()
            }
        }
    }
}

goAsync()がここに登場するのは、BroadcastReceiverと同じ理由だ — onClick()は速やかにリターンすることが期待されており、メインスレッドでのRoom書き込みはシェードの収束アニメーションをカクつかせてしまう。タイルはマニフェストでBIND_QUICK_SETTINGS_TILE権限とともに宣言しなければならず、そして — ここが人を引っかける部分だが — どこにも自動的には表示されない。ユーザーはシェードの編集画面から自分でアクティブなタイルへドラッグする必要がある。TileService.requestListeningState()はAndroid 13+に追加を提案させることができるが、配置を強制する方法はなく、ほとんどのユーザーがわざわざそうするとは期待できない。タイルは主要な操作経路ではなく、パワーユーザー向けの機能として扱うこと。

タイルの状態を正直に保つ

アプリの状態に関係なく常に同じアイコンを表示するタイルは、状態と一致しない瞬間に壊れているように見える。Hydrameの1日の目標がすでに達成されているなら、onStartListening()がそれを反映する場所だ。

override fun onStartListening() {
    super.onStartListening()
    lifecycleScope.launch {
        val goalMet = intakeDao.todayTotal() >= goalDao.currentGoal()
        qsTile?.apply {
            state = if (goalMet) Tile.STATE_INACTIVE else Tile.STATE_ACTIVE
            subtitle = if (goalMet) "Goal reached" else null
            updateTile()
        }
    }
}

onStartListening()は、タイマーではなく、あなたのタイルが含まれるシェードが表示されるたびに発火する。そのため、この読み取りは構造上安価かつ常に最新だ — ポーリングも、同期を保つためのバックグラウンドジョブも要らない。

これを実装する価値があるもの

どちらのAPIも、ドキュメントからは自明ではない2つのルールさえ知っていれば難しくない — ショートカットはバックグラウンドの書き込みであってもActivityを必要とすること、そしてタイルは通知アクションのレシーバーと同じ理由でgoAsync()を必要とすること。得られるものはコード量に見合わないほど大きい — 長押しやシェードのタップが、誰かが毎日繰り返すわずかなアクションについて、アプリのフル起動を置き換えてくれる。それはユーザーが意識的には気づかない類の摩擦であり、取り除いたときにその不在にしか気づかれない。1つか2つの支配的なアクション — 何かを記録する、何かを開始する、何かを切り替える — を持つどんなアプリにとっても、半日をかける価値がある。