Help us understand the problem. What is going on with this article?

Androidで毎秒カウントダウンをするappWidgetを作ってハマりまくった話

この記事はフラー Advent Calendar 2019の 9 日目の記事です。

この記事で何が言いたいか

最終的な着地点は「ちゃんと技術的な見積もりをしてから開発のGOを出さないと痛い目を見るよ」という話です。
そんな常識的なことはわかってるから、技術的にどうやって作ったんやって方は後半パート
まで飛んでください。

技術見積もり

今回の開発はタイトルにある通り「毎秒カウントダウンするappWidget」を開発して欲しいという案件でした。
開発が規模的にそんなに大きくない&開発に時間がかけられないということもあり、今回は「すでに同等の機能がついているアプリがストアに存在するか」「公式ドキュメントをみていけそうか」という二つの観点でざっくり「いける」「いけない」を見積もることにしました。

すでに同等の機能がついているアプリがストアに存在するか

こちらに関してはいくつかあったのですが、ざっと調べたところ不安定なものが多い印象でした。
アプリをバックグラウンドでキルすると止まっているアプリ、Widgetを表示してしばらく置いておくと勝手にカウントダウンが止まっているアプリなどが散見されました。
ただ、その中でもちゃんと動いているアプリがいくつかあったので、技術的には可能で難易度もそこそこかなと判断しました。

公式ドキュメントをみていけそうか

こちらもいけそうだなと判断しました。
updatePeriodMillisは最速でも30分間隔でしか刻めないことは理解していて、その上で以下の公式ドキュメントを読んで「更新頻度を高くする場合はAlarmManagerを使えば何とかなりそう」という結論に至りました。(AlarmManagerなところが肝要)
https://developer.android.com/guide/topics/appwidgets?hl=ja#MetaData

最初のアプローチ (Observer.interval)

問題なくいけそうだという判断を自分の中で下したので、ディレクターの方には色良い返答をして実装開始です。
AlarmManagerを使えと書いてありはしましたが、技術が課題に対してちょっと大きいなと思ったので最初はcountDownTimerObserverを使ったループで毎秒カウントを実現し、それをAppWidgetProvider内で動かすといった形の実装を取ってみました。

こんなやつをAppWidgetProviderのonUpdateから呼ぶ感じです。

private fun setCountDownTimer(context: Context, appWidgetId: IntArray) {
        Observable.interval(TIMER_COUNT_DOWN_INTERVAL_MILLIS, TimeUnit.MILLISECONDS)
            .timeInterval()
            .observeOn(AndroidSchedulers.from(Looper.myLooper(), true))
            .subscribe {
                val intent = Intent(context, ClockWidget::class.java)
                intent.action = ACTION_UPDATE
                val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0)
                pendingIntent.send()
            }.addTo(disposable)
    }

しかしこの実装だと、あくまでカウントダウンはAppWidgetProvider上で動いているので、アプリがKillされるとWidgetのカウントダウンも止まってしまうことがわかったので、無事不採用になりました。

次のアプローチ (Chronometer)

では発想を変えて、UI側から攻めれないかと思ってChronometerの利用を考えました。
しかし、「カウントダウンする値が1年刻みとかなり大きい点」や「カウントダウン終了後、数時間して別の処理を行う必要がある点」などの要件があり、それを考慮したときに不可能だという判断に至りました。

さらに次のアプローチ (AlarmManager)

ならしょうがないと、公式ドキュメントのいう通り、AlarmManagerを使って実装してみました。
AlarmManagerはアプリの実行期間外に時間ベースの処理を行うことができるので、最初のアプローチの問題は解決できるはずです。

こんなやつ

val alarmManager: AlarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
        val intent = Intent(context, WidgetService::class.java)
        pendingIntent = PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT)
        val time: Calendar = Calendar.getInstance()
        time.set(Calendar.MINUTE, 0)
        time.set(Calendar.SECOND, 0)
        time.set(Calendar.MILLISECOND, 0)
        alarmManager.setRepeating(AlarmManager.RTC, time.time.time, 60 * 1000, pendingIntent)

えぇ、勘の良い方はおわかりだと思います。
AlarmManagerはそもそも正確性を求めて使うものではなく、呼び出し方にもよりますが最速でも5秒間隔でしか呼び出すことができません。
また、端末の状態(Dozeモードなど)の影響も受けやすく、端末を放置すればするほど挙動は不安定になっていったためこちらも不採用。

さらに次の次のアプローチ (ForegroundService)

実装の時間が取れないけど、思ったより難易度が高いんじゃないかとこの辺から焦り始めます。
納期も迫っていたため、妥協案として通知領域に常に「カウントダウン中です」と言った表示をしつつ、Serviceを使ってWidgetでカウントダウンをするという方向をディレクターと探り始めたのがこの時期でした。

結局ForegroundService使う許可は降り、実装的にも問題ないところまでできたのですが、その後に解決策を思いついたのでお蔵入りになりました。

最後のアプローチ (RemoteViewsService)

結論としてRemoteViewsServiceを使って解決することができました。
RemoteViewsServiceはもともとAppWidgetに切り替え可能なリストを提供するためのServiceでRemoteViewsFactoryとセットで使います。
そしてRemoteViewsServiceの実装はServiceをExtendしており、尚且つ通知領域に通知を出し続けなくて良い形になっているのです。
なので、RemoteViewsService上でObserverでカウントを行い、毎回notifyAppWidgetViewDataChangedを発行してRemoteViewsFactoryからRemoteViewを返してもらうという形の実装を行い、ギリギリ実装を終えることができました。

実際の実装(一部抜粋)

class WidgetRemoteViewsService : RemoteViewsService() {
    private val disposable = CompositeDisposable()

    override fun onCreate() {
        super.onCreate()
        val appWidgetManager = AppWidgetManager.getInstance(this)
        setCountDownTimer(appWidgetManager)
    }

    override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
        return WidgetRemoteViewsFactory(this)
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        return START_REDELIVER_INTENT
    }

    override fun onDestroy() {
        disposable.dispose()
        super.onDestroy()
    }

    private fun setCountDownTimer(
        appWidgetManager: AppWidgetManager
    ) {
        Observable.interval(TIMER_COUNT_DOWN_INTERVAL_MILLIS, TimeUnit.MILLISECONDS)
            .timeInterval()
            .observeOn(AndroidSchedulers.from(Looper.myLooper(), true))
            .subscribe {
                // Loop中にWidgetを追加されたときに追加されたWidgetのカウントダウンも正常に動かすためにこのタイミングでappWidgetIdsを読み込む
                val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(this, ClockWidget::class.java))
                appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.adapterCountDownView)
            }.addTo(disposable)
    }
class WidgetRemoteViewsFactory internal constructor(private var context: Context) : RemoteViewsFactory {

    private fun createWidgetRemoteViews(): RemoteViews {
        val remoteViews = RemoteViews(context.packageName, R.layout.widget_clock)
        val timeZone = ZoneId.of("Asia/Tokyo")
        val countDownMillis = xxCountDownTimer.getCountTime(ZonedDateTime.now(timeZone))
        val duration = Duration.ofSeconds(countDownMillis)
        remoteViews.setTextViewText(R.id.countDownTextView, DurationStringConverter().convertDurationString(duration))
        return remoteViews
    }
}

終わってから考えたこと

まぁ冒頭でも言いましたが、「ちゃんと技術的な見積もりをしてから開発のGOを出さないと痛い目を見るよ」という一言に尽きるなと思いました。
通常のアプリ内での実装であれば、まだ結構先駆者もいてなんだかんだ何とかなったりするイメージはありますが、Widgetに関してはあまり技術文献もネットに落ちておらず、Githubのソースコードを結構深くまで漁ったりとかもしました。
その技術が新しいものか、ネットに文献が落ちているかどうかも見積もり要素としていれたほうがいいなと改めて思い知らされました。

私の経験が、誰かのために役に立ったら良いなとおもいここに書き連ねておきます。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away