とても古いAndroidアプリを更新してみた話
はじめに
遙か太古の時代、 Eclipse 全盛期時代に作られた古い Android アプリを大改修してリリースしてみた話です。
筆者は気象庁ホームページの HTML をパースして天気予報やアメダスの情報を表示する「WeatherNow」というアプリを開発していました。初期リリースは Android のバージョンがまだ 1.6 や 2.1 の時代で、そこそこ思い入れがあるアプリです。特にメンテナンスをしなくても安定して動いていたアプリでしたが、最近気象庁ホームページが大幅にリニューアルされた結果、完全にお亡くなりになりました。
少なくない数の対応を依頼するメールを頂いていたのは気付いていましたが筆者が最近多忙にしていることもあり、全く改修するつもりはありませんでした。しかし仕事に余裕が出始めたこと、最近の Android 事情を肌で感じてみたかったことから、とりあえず直してみるかと重い腰を上げて取りかかりました。
ガンガン Android 開発されている方には不要な内容でしょうが、久しぶりに古いアプリを改修してみようという方がいらっしゃれば、参考になるかも知れません。
余談ですが筆者は普段から JetBrains 系の IDE を愛用しており、また Java 8 や正式リリース前の Kotlin を一通り触った経験があるため、今回の改修に関してかなりアドバンテージがあったことだけは先に記しておきます。なお、後半は息切れしたため内容が雑になってしまっていることを先に記しておきます。
改修前後の大きな違い
WeatherNow は前述の通り Eclipse でビルドされていたアプリです。 Play Console の履歴を見ると直近の更新は2013年に1回、2017年1回でした。2017年に改修したときは何を使って直したんでしょうか。全く記憶がありません。そして何故か Git のコミットもされていませんでした。おこだよ。
まずはザックリとした差分表です。
旧 | 新 | |
---|---|---|
IDE | Eclipse | Android Studio 4.1 |
開発言語 | Java 6 / Java 7 | Java 8 + Kotlin@1.4.31 + Coroutine@1.4.3 |
Target SDK | 11 (Android 3.0) | 30 (Android 11) |
Min SDK | 8 (Android 2.2) | 21 (Android 5.0) |
Support Library | 13 | 28 |
定期実行 | AlarmManager | JobScheduler |
HTTP Client | Apache HTTPClient / HttpURLConnection | ← + OkHttp3@4.9 + moshi |
SQLiteの扱い | SQLiteOpenHelper | ← + Room@2.2.6 |
日時の扱い | Date / Calendar | ThreeTenABP |
だいぶ変わりました。正直ここまで変更する予定は無かったのですが、あれよあれよと色々詰め込んでしまいました。デグレが心配でなりません。ここからはそれぞれの対応ポイントについて記していきます。
開発言語のハイブリッド化
まず開発言語について、今回の改修ではなるべく従来のコードを残しつつ、新規実装部分(気象庁ホームページからデータを取得・格納する部分と最新 Android 仕様に対応するための部分)を Kotlin で実装することにしました。
Kotlin を採用した理由を覚えている限り箇条書きにしてみます。
- 名前も見た目もかわいらしくて良い
- 最近のサンプルは大体 Kotlin なので採用しない手は無いと思った
-
data class
を使えば lombok など使わなくてもスッキリ書けて良い -
var
let
やlet
use
などの拡張関数でスッキリ書けて良い - 言語レベルで組み込まれた null 安全が良い
- あわよくば非同期を Coroutine にしてみたい
筆者は普段の業務で C# や TypeScript を扱っていますので Kotlin に取り入れられた機能は日常から慣れ親しんでいるものであり、また以前一通り Kotlin は触っていますので、採用する敷居はとても低かったです。
ターゲット SDK の更新
続いてビルドに使用する SDK バージョンについてです。これは Google Play Store で配布するアプリである以上選択肢は無く、上げざるを得ません。
ターゲット SDK とホームウィジェットの仕様
改修前のアプリはターゲット SDK を Android 黒歴史とも呼ばれる 11 (Android 3.0)に設定していました。なんとも微妙な値です。これはホームウィジェットの Android 4.0 における「勝手にパディング入れちゃう」仕様変更を受けないための措置でした。ホームウィジェットの中身を作り込んでいるアプリにとって、勝手にパディングが入る、セルサイズの計算方法が変わる、というのは死活問題です。
今回は時間の都合もあり、ホームウィジェットを売りにしていたアプリではありますがセルサイズや見た目に一切の調整を行わずリリースすることとしました。未だにどう対応すべきか悩んでいる箇所の一つです。
余談ですが改修前の MinSDK が 8 だったのは、このバージョンでホームウィジェットを描画するために利用している RemoteViews
が入れ子構造をサポートするようになったからだったと記憶しています。それ以前の Android で複雑な View を持つホームウィジェットをサポートするためには一枚のレイアウト XML ファイルに全ての構造を書く必要があり、管理地獄でした。
改修後の MinSDK は Runtime Permission が始まった23か、 JobScheduler が始まった21かで悩みましたが、 Android 5.0 のユーザーさんがすぐ近くにいらっしゃったため21としました。
非推奨になった(削除された)Apache HTTPクライアント
ターゲット SDK バージョンを上げてコンパイルして気付くのが、最新 SDK では Apache HTTPクライアントをサポートしていない点です。全て書き直そうかとも考えましたが、今やらなくても良い作業のため、 Manifest に一行足すだけとして全面的な再実装は見送りました。
参考:
ただし新規の HTTP 接続箇所は HTTP クライアントに OkHttp3 を、 JSON 処理に moshi を採用しています。 Gson が非推奨となり慣れている Jackson かシンプルな moshi かで悩みましたが、複雑な処理をする予定が無く Jackson ではオーバースペックになりそうだと感じたため moshi を利用することとしました。
Runtime Permission と Support Library の更新
ターゲット SDK バージョンを上げて通ることを避けられない道がこれでしょう。 電話帳アクセス、位置情報、外部ストレージなど、危険な権限でも Android 5 までは Manifest に一覧記述するだけで利用できていましたが、 Android 6 以降は利用するタイミングでユーザーから明示的な許可を得なければならなくなりました。この仕様変更に対応するには Activity
や Fragment
に生えている requestPermissions
メソッドを利用しなければなりませんが、改修前に利用していた Support Library v13 上の Fragment は対象とする SDK バージョンが古かったためメソッドが存在しておらず、どうにもできなくなりました。 Support Library を上げなければならないと気付いた瞬間です。
これはゴタゴタしそうだととても身構えていたのですが、さすがというのか、幸いというのか、分かりませんが、利用する Support Library のバージョンを最新である28に更新しても一切エラーは発生しませんでした。
Runtime Permission への対応自体もかなり身構えていた部分だったのですが、数カ所の変更だけで済みました。どちらかというとコードの変更よりも既存ユーザーの Runtime Permission への移行をどうすればよいのかとても悩み、アプリ初回起動時に設定値を見て位置情報の権限が必要なら Runtime Permission を要求すべく実装しましたが、実は既存ユーザーはアプリをインストールした時点で権限がフルで付いていたため何の問題もありませんでした。。
余談ですが最近は Support Library 自体が非推奨になって AndroidX 名前空間に変わったそうですね。 WeatherNow では当時?今も? Support Library でサポートされていなかった PreferenceFragment
を Android 2.x にバックポートするために Support Library 上にゴリゴリの実装を行っており、 AndroidX への更新は敷居が高く諦めました。
Room
WeatherNow は気象庁ホームページからアメダスや天気予報の情報を取得し、 SQLite3 データベースに保存しています。今回の改修では気象庁ホームページから取得できるデータ量や天気・アメダス情報を取得するための地域情報の持ち方が変更となったためデータベースの差し替えも必要でした。以前利用していた SQLiteOpenHelper
をそのまま利用するのか、 Google が推しているらしい Room を採用するのか悩みましたが、 Cursor
を直接使わなくても良いことやマッパー機能があることから Room を利用することにしました。
Room は JPA(Java Persistancce API)の様に、 Entity や Context にアノテーションを付けておくとアノテーションの情報を元にクエリやマッピング処理を生成してくれるライブラリです。実装の都合上 Support Library からは抜け出せないため AndroidX になる前のバージョンである Room@1.1.1 を最初は導入しましたが、これは Kotlin のバージョンとの相性が悪いのかコンパイルを通すことができず、結局 AndroidX な最新バージョンを使うこととなりました。おそらく旧 Support Library と AndroidX Support とを同時に利用しなければ大丈夫でしょう。。
アノテーションを付けると自動でクエリを生成してくれる機能はとても便利なのですが、最低限の CRUD まで全部アノテーションを付けて準備しなければならないのは実に手間でした。そのくらいは自動で生成してくれても良いのでは。。。
また使用する Entity クラスにアノテーションを直接付けなければならないため、別のプロジェクトで実装した data class をそのままデータベースに投げ込む・取得するということができません。コードからアノテーションと同等の設定ができる EntityFramework Core の Fluent API みたいな機能が欲しかったです。
参考:
Room と UI スレッドと Coroutine
Room を使ってロジックを組みいざ動作させてみるという段階で気付いたのですが、 Room を使用したデータベースへのアクセスは UI スレッド上で行うと例外がスローされます。よくよく調べて見ると最初の動作確認していた箇所が偶然非同期で取得していただけで、アプリ全体で見ると UI スレッドからデータベースを操作している箇所の方が多くありました。あまり UI 側に手を入れたく無いため、手っ取り早く実装でっきる非同期対応が必須です。そう、 Coroutine です。
Coroutine は Kotlin で利用できる軽量なスレッド機能で、手軽に非同期操作を記述することができます。筆者は何も考えず、 Room を利用したデータベースアクセスを UI スレッドから排除すべく、 runBlocking (Dispatchers.IO)
を乱発してしまいました。非同期にする意味が全く無いですね。
Coroutine はいわゆる Kotlin 版 async/await と聞いていたのですが 、 Scope の概念がなかなかに分かりづらかったです。また C# や JavaScript の async/await に慣れていると suspend を待つのにそのままメソッドを呼び出すだけで良いという仕様が気持ち悪く感じられました。そして Coroutine についてググると出てくる記事が大体 Coroutine 初期の頃のもので情報が古く、記事通り実装しようとしてもコンパイラに怒られるのが辛かったです。
AlarmManager と暗黙的 Intent と JobScheduler
Android 4.x 時代で開発していた WeatherNow は定期実行のために AlarmManager でタイマーを登録して BroadcastReciever を呼び出し、 BroadcastReciever から更新処理を実行するクラスを利用する Service をキックしていました。しかし Android 8 以降をターゲットとする場合 BroadcastReciever で暗黙的 Intent が処理できなくなるため、定期実行処理を全て JobScheduler に置き換えました。
AlarmManager はアプリがキルされると登録していたタイマーが削除されるなど残念な挙動を示していました。 JobScheduler ではアプリがキルされてもタイマーが削除されずに安定した実行が可能な上、更にバッテリー残量やネットワーク状態に応じて Job を実行するかどうかを事前に定義することができます。 今まで暗黙的 Intent を投げて適当に処理していた部分を全て置き換えることにはなりましたが、定期実行処理の置き換え自体はトラブルも無くスムーズに変更できました。
Notification と NotificationChannel
Android 8 以降は Notification を表示するには NotificationChannel を作成し、全ての Notification は NotificationChannel を通じて表示する必要があります。
表示している Notification の分類さえしてしまえば、既存の通知処理を少し変更するだけで実装できました。
Android 11 と ExternalStorage の仕様変更
Android 11 から外部ストレージへの権限が変更され、自身のデータディレクトリ以外への書き込みができなくなりました。 WeatherNow では気象系データベースやスキンファイルの置き場として外部ストレージ上のスコープ外ディレクトリを利用していました。
これらは SD カード上のディレクトリでなくても良いため、前者は Room に置き換えて内部 data ディレクトリへ、後者は外部ストレージ上のスコープ内へと移動しました。また外部ストレージからファイルを選択する機能を実装していましたが、 Intent.ACTION_OPEN_DOCUMENT
へと置き換えました。
その他細かな変更点
Activity の再起動ができない
WeatherNow はアプリの初回起動時に初期化専用の Fragment を表示し、初期化処理が終了したら AlarmManager で MainActivity を再起動する処理を実装していました。
この処理は Android 10 で使用できなくなっていたため、 Activity 再起動用の Activity を利用することで回避しました。
参考:
重ねた Button の Z-index がおかしい
一部の AppWidget でボタンを重ねて表示している箇所があるのですが、何故かボタンの前後が逆になってしまっていました。
調べると Android 5 以降ではボタンの z-index に謎の処理があるとのことで、 XML 上に android:stateListAnimator="@null"
を指定することで解決できました。
ContentProvider と getType
実は WeatherNow は、気象庁ホームページの仕様が変更される以前から Android 11 で AppWidget が表示されないバグがありました。これの原因は ContentProvider の実装不足に依るものでした。
AppWidget 上で表示している画像は、画像を Intent に詰めるとサイズオーバーになることから ContentProvider で配信しています。特定の処理しかしていなかったため openFile のみを実装してそれ以外のメソッドは全て例外をスローする実装としていたのですが、 Android 11 からは openFile 呼び出し時に getType も呼び出される仕様変更があったようです。
Discussion