二年半ぶりのアプリアップデート作業

約二年半ぶりにAndroid向け喫煙管理アプリSmoking Noteのアップデートを行いました。Google PlayやAndroid OS、開発環境などが変わったことにより、単純にソースコードを修正してビルドしアップロードしておしまい、とはいかず予想していたよりも工数がかかりました。せっかくですので今回のアップデート作業についてまとめておきます。

環境の変化

現在Google Playではアプリの対象 API レベル(targetSdkVersion)要件を遵守することを義務付けています。対象 API レベルは毎年のようにバージョンアップされるAndroid OSに対してアプリの互換性を維持するために使われます。アプリに最新の対象 API レベルを設定することで、ユーザー側のセキュリティ、プライバシー、パフォーマンスが向上します。

アプリの対象 API レベル要件を以下に記します。

定義
新しいアプリ Play ストアでまだ公開されていないアプリ(最新のアプリなど)
アプリのアップデート Play ストアですでに公開されているアプリの新しいバージョン
既存のアプリ アップデートが配信されていない公開中のアプリ
要件
Android OS のバージョン Google Play アプリがこの API レベルを対象とすることが義務付けられる時期
新しいアプリ アプリのアップデート 既存のアプリ
Android 12(APIレベル31) 2022年8月1日 2022年11月1日 2023年11月1日
Android 11(APIレベル30) 2021年8月2日 2021年11月1日 2022年11月1日

引用元:Google Play アプリの対象 API レベル要件

さしあたり2022年11月1日までにアプリの対象APIレベルを30以上にしないとAndroid 11以上のデバイスを使用しているユーザーからはアプリをインストールしてもらえなくなります。

また、対象APIレベル要件は以下のルールで今後も更新されていくようです。

アップデートのない既存の Google Play アプリ: Android の最新のメジャー バージョンのリリースから 2 年を過ぎてもその Android API レベルをターゲットにしていないアプリは、それ以降のバージョンの Android OS を搭載したデバイスの新規ユーザーからアクセスできなくなります。

Google Play の対象 API レベルに関するポリシー

参考までに最近のAndroid OSのバージョン履歴を以下に記します。

コードネーム バージョン リリース日 API レベル
Q 10 2019年9月3日 29
R 11 2020年9月8日 30
S 12 2021年10月4日 31
Sv2 12L 2022年3月7日 32
Tiramisu 13 33 (予定)

引用元:Androidのバージョン履歴 – Wikipedia

開発環境

前回(1.3.3)と今回(1.4.0)の開発環境を記します。

Smoking Note 1.3.3 (2019/11) 1.4.0 (2022/6)
Cordova 9.0.0 11.0.0
Cordova Android 8.1.0 10.1.2
targetSdkVersion 28 31
  • UIフレームワークOnsen UI(Monaca Version)はv2.10.10で変更なし
  • minSdkVersionは21から23に変更

アップデート作業

ローカルストレージのデータが読み込めない

本アプリではローカルストレージに当日分の喫煙時間を記録し、日付変更時間を経過したとき当日分の記録を整形してファイルへ保存しています。アップデート版では前バージョンで作成したローカルストレージやファイルのデータにアクセスすることになります。

アプリのアップデートによるデータ引継ぎ動作を確認したところ、ローカルストレージからデータが読み込めていませんでした。原因はCordova Androidのバージョンを8.1.0から10.1.2に上げたことでした。

Cordova Android 10.0.0からWebViewAssetLoaderがサポートされ、リソースのアクセス方法が変更されました。

Cordova Android 10より、WWWディレクトリ配下のリソースアクセスの方法が更新されました。

従来はfileスキームによるリソースアクセスでしたが、http(s)スキームによるアクセスに変わりました。そのため、Cordova Android 10より古いバージョンでローカルストレージやクッキー等に保存したデータは、スキームが異なるCordova Android 10ではアクセスができなくなります。

Cordova Android 10.1.1 サポート – モナカプレス

アップデート版から前バージョン(fileスキーム)で作成したローカルストレージのデータを読み込むためには以下の設定が必要になります。

下記の設定をconfig.xmlに指定することで、Cordova Android10においてfileスキームを継続して利用できます。fileスキームの利用は、Androidにて非推奨となりましたが、暫定対応として設定できます。

<preference name=”AndroidInsecureFileModeEnabled” value=”true” />

Cordova Android 10.1.1 サポート – モナカプレス

ちなみにファイルアクセスについては上記の設定がなくても問題ありませんでした。

ターゲットSDKバージョンを31に設定したらアプリが立ち上がらない

アプリのターゲットSDKバージョンを30に設定していたときは問題なく動作していたのですが、ターゲットSDKバージョンを31に上げたところアプリが立ち上がらなくなりました。原因はCordova AdMob PlusプラグインでGoogle Mobile Ads SDKのバージョンを設定しなかったことでした。

詳細については前回の記事をご参照ください。

Cordova InAppBrowserプラグインが動作しない

Smoking Note 1.3.3 (2019/11) 1.4.0 (2022/6)
cordova-plugin-inappbrowser
3.0.0 5.0.0

Cordova InAppBrowserプラグインを利用するとInAppBrowserウィンドウ(専用のWebブラウザ)からWebサイトを安全に開くことができます。

本アプリではインストールから2週間経過したときにレビューのお願いダイアログを表示し、[評価する] ボタンを押すとGoogle PlayストアをInAppBrowserウィンドウで開きます。

今回のアップデート作業とは直接関係がない機能でしたがたまたま動作確認したところ [評価する] ボタンを押しても何も起こらなくなっていました。

調べたところ v4.0.0 でwindow.open関数をcordova.InAppBrowser.open関数で上書きするコードが削除されたようです。このため自分のソースコード上でwindow.openの記述をcordova.InAppBrowser.openに書き換えて対応しました。

グラフ描画のJSライブラリでWarningが発生

USBデバッグ時にグラフをタッチスライドすると以下のWarningが大量に発生しました。

[Intervention] Ignored attempt to cancel a touchmove event with cancelable=false, for example because scrolling is in progress and cannot be interrupted.

対策としてcancelableがtrue(イベントがキャンセル可能)の場合のみpreventDefaultメソッドを実行するように修正しました。これによりWarningが発生しなくなりました。

// 変更前
if (event[preventDefault])
  event[preventDefault]()
else
  event.returnValue = false

// 変更後
if (event[preventDefault]) {
  if (event.cancelable) {
    event[preventDefault]()
  }
} else {
  event.returnValue = false
}

ons-dialogに配置したボタンが表示されない

本アプリのご利用者から日付変更時間の設定ができないというお問い合わせを頂きました。確認してみると日付変更時間の設定ダイアログ上にあるはずの [キャンセル/設定] ボタンが消えていました。よくみると[18:00] の横にあるはずの [21:00] のボタンも表示されていません。

修正前の日付変更時間設定ダイアログ

このときのindex.htmlを以下に記します。[キャンセル/設定] ボタン(48-51行)と [21:00] のボタン(40-43行)はちゃんと記述してあります。

<ons-template id="dateline.html">
  <ons-dialog id="dateline_dlg">
    <div class="alert-dialog-title alert-dialog-title--material" data-i18n="setting.dateline.title"></div>
    <div class="alert-dialog-content alert-dialog-content--material">
      <div class="dateline_container">
        <span style="margin: 0 0 10px 0; font-size: 14px;" data-i18n="setting.dateline.forward"></span>
        <div class="segment st_btn_segment">
          <div class="segment__item">
            <input type="radio" class="segment__input" name="segment-a" value="0" checked>
            <div class="segment__button">0:00</div>
          </div>
          <div class="segment__item">
            <input type="radio" class="segment__input" name="segment-a" value="3">
            <div class="segment__button">3:00</div>
          </div>
          <div class="segment__item">
            <input type="radio" class="segment__input" name="segment-a" value="6">
            <div class="segment__button">6:00</div>
          </div>
          <div class="segment__item">
            <input type="radio" class="segment__input" name="segment-a" value="9">
            <div class="segment__button">9:00</div>
          </div>
        </div>

        <span style="margin: 24px 0 10px 0; font-size: 14px;" data-i18n="setting.dateline.backward"></span>
        <div class="segment st_btn_segment">
          <div class="segment__item">
            <input type="radio" class="segment__input" name="segment-a" value="12">
            <div class="segment__button">12:00</div>
          </div>
          <div class="segment__item">
            <input type="radio" class="segment__input" name="segment-a" value="15">
            <div class="segment__button">15:00</div>
          </div>
          <div class="segment__item">
            <input type="radio" class="segment__input" name="segment-a" value="18">
            <div class="segment__button">18:00</div>
          </div>
          <div class="segment__item">
            <input type="radio" class="segment__input" name="segment-a" value="21">
            <div class="segment__button">21:00</div>
          </div>
        </div>
      </div>
    </div>

    <div class="alert-dialog-footer alert-dialog-footer--material">
      <ons-alert-dialog-button onclick="onCloseDateline('ok')" data-i18n="btn.set"></ons-alert-dialog-button>
      <ons-alert-dialog-button onclick="onCloseDateline('cansel')" data-i18n="btn.cancel"></ons-alert-dialog-button>
    </div>
  </ons-dialog>
</ons-template>

このときのDOMは以下のようになっていました。[21:00] のボタンを内包する4つ目のdev.segment__item要素と [キャンセル/設定] ボタンを内包するdiv要素がまるごとありません。とりあえず表示上の問題ではないことはわかりました。

修正前のDOM構造

かなりやっかいな状況でしたが、ソースコードを見直したりいろいろ試しているうちにうまくいきました。根本的な原因はわかりませんでしたがHTMLのons-templateの記述をtemplateに変更したところ意図したとおりのダイアログが表示されました。

修正後の日付変更時間設定ダイアログ
修正後のDOM構造

Onsen UIの公式ガイドには以下のように記されています。

<ons-template>要素も同じ目的で利用できますが、バージョン2.4.0以降はネイティブの<template>要素を使うことを推奨します。

テンプレート – Onsen UIガイド

また、以下のようなissueも見つけました。こちらでもons-templateコンポーネントの代わりにtemplate要素を使用する必要があると記されています。

Deprecate or remove ons-templat – Onsen UI issues#2965 

なぜons-templateコンポーネントを使用すべきではないのか、使用するとどうなるのか等の具体的な情報は見つけることができませんでした。ons-templateコンポーネントのコンパイルやパーサーに関わる問題でしょうか?なんにせよ難しそうな感じがします。

最新版のOnsen UI v2.12.0ではons-templateコンポーネントは削除されたようです。

LocalStorageからNativeStorageへ変更したときの初期化処理

これまでローカルストレージを利用していましたが、今回からCordova NativeStorageプラグインを利用することにしました。プラグインについては以前に記事にしましたのでご参照ください。

アプリ起動時の初期化処理について、変更前の動作フローの概要を以下に記します。まずLocalStorageの読み込みを行い、次にinitイベントのリスナーでパラメータAを参照した処理、最後にdevicereadyイベントのリスナーでパラメータBを参照した処理を行います。

変更前(LocalStorageを利用)

NativeStorageプラグインを利用する場合、Cordovaの初期化が完了するのを待つ必要があるためdevicereadyイベントのリスナーでNativeStorageの読み込みを行います。パラメータを参照して行う処理はこの後に行う必要があるため、initイベントのリスナーで行っていた処理はdevicereadyイベントのリスナーへ移動させる必要があります。

変更後(NativeStorageを利用)

実は実装時にinitイベントリスナー内のパラメータを参照する処理の移動が抜けてしまいました。たった一行、パラメータを参照する関数呼び出しが隠れていました。おかげでSmoking Note v1.40のリリースから一週間足らずで修正版のv1.4.1をリリースする羽目になりました。

ということで、喫煙管理アプリ Smoking Note は Google Play ストアから無料ダウンロードできます。

Google Play で手に入れよう

Cordova AdMob Plusプラグインで広告を表示

CordovaのAdMobプラグインとして cordova-plugin-admobproプラグイン、cordova-plugin-admob-freeプラグインと利用してきましたが、AdMob Freeのリポジトリがアーカイブされメンテ終了となったため、AdMob Freeの後継となるadmob-plus-cordovaプラグインを使ってみました。以下にMonacaクラウドIDEでの実装手順を記します。

Memo

アーカイブされましたがAdMob Freeも現在の環境で動作することを確認しました。以前に書きましたAdMob Freeの記事の内容に加えて、cordova-plugin-androidxプラグインとcordova-plugin-androidx-adapterプラグインの2つをインポートする必要があります。

環境

  • 開発環境:MonacaクラウドIDE
  • Cordova:11.0.0
  • ビルド環境:Cordova Android 10.1.2
  • ターゲットSDKバージョン:31
  • 対象OS:Android
  • 利用するプライグイン:

実装手順

1.AdMob Plusプラグインをインポート

Cordovaプラグインの設定画面からパッケージ名を入力してAdMob Plusプラグインをインポートします。

パッケージ名/URL :admob-plus-cordova

2.インストールパラメータ設定

インポートしたadmob-plus-cordovaプラグインの設定ボタンを押してインストールパラメータの入力画面を表示します。



AdMobアプリIDとGoogle Mobile Ads SDKのバージョンを設定します。

インストールパラメータ :

APP_ID_ANDROID=ca-app-pub-3940256099942544~3347511713

PLAY_SERVICES_VERSION=21.0.0

Memo

  • AdMobアプリIDの名前はAdMob FreeではADMOB_APP_IDでしたがAdMob PlusではAPP_ID_ANDROIDに変更されています。
  • 上記の説明で使用しているAPP_ID_ANDROIDの値はサンプルのアプリIDの値です。実際はAdMobで登録した値を入力します。
  • PLAY_SERVICES_VERSIONの記述がない場合はデフォルトの20.4.0が設定されます。このバージョンのGoogle Mobile Ads SDKにはAndroid S(APIレベル31)でアプリがクラッシュするバグがありandroidx.work:work-runtime:2.7.0 に対する明示的な依存関係を指定する必要があります(Mobile Ads SDK(Android)リリースノートのバージョン20.4.0の項参照)。 実際にPLAY_SERVICES_VERSIONを記述せずにアプリをビルドし実行してみると「アプリ名」が繰り返し停止していますと表示されアプリが立ち上がりませんでした。
  • APP_ID_ANDROIDを設定していなかったり不正な値を設定した場合も同様にアプリが立ち上がらない状態になります。
  • APIレベル30、Cordova Android 10.1.1の環境ではPLAY_SERVICES_VERSIONを記述しなくても問題なく動作します。ただしGoogle PlayのターゲットAPIレベル要件の更新により今後APIレベル31への対応が必要になります。

上記の設定を行うとpackage.jsonに以下のように反映されます。

3.コーディング

AdMob Plusの公式ドキュメントに従いコーディングを行います。一例として以下に自作アプリで実際に使用しているソースコードの一部を記載します。showAdMob関数が呼ばれるたびにインタースティシャル広告を表示します。

var gAdMob = 0;

// 広告の表示
async function showAdMob() {
  if (gAdMob) {
    await gAdMob.show()
  }
}

// AdMobの初期化処理(一回だけ実行)
async function init_ad() {
  if (window.admob) {
    // AdMob SDKの初期化
    await admob.start();

    // インタースティシャル広告の生成
    gAdMob = new admob.InterstitialAd({
      adUnitId: 'ca-app-pub-3940256099942544/1033173712'  // test id
    });

    // 広告の読み込み
    await gAdMob.load();

    // 広告を閉じたときに発生するイベントのリスナーを登録
    document.addEventListener('admob.ad.dismiss', async () => {
      console().log("admob is dismiss. reload");
      // show()関数で再表示させるための読み込み
      await gAdMob.load();
    });
  } else {
    console.log("admob is undefined");
  }
}

vue-i18n を使ってみました

vue-i18n は Vue.js 向けの国際化(多言語化)プラグインです。

今回アプリのアップデート版を作るにあたり、Monaca の Onsen UI + Vue プロジェクトで vue-i18n を使ってみました。

以下に備忘録を兼ねた使用例を記します。

インストール

npm install vue-i18n

ファイル構成

  • 対応する言語は日本語、英語、韓国語、中国語(簡体)、中国語(繁体)、スペイン語です。それぞれ JSON ファイルに分けて記述します。
  • i18n.js では vue-i18n のインスタンスを作成します。
  • main.js では Vue 本体のインスタンスを作成しています。そこに i18n.js で作成した vue-i18n のインスタンスを追加します。

以下、ファイルごとに説明します。

言語ファイル

日本語用の言語ファイル ja.json の一部を以下に記します。

{
  "cancel": "キャンセル",
  "set": "設定",
  "del": "削除",
  "sound": {
    "title": "通知音",
    "type": [
      "バイブレーション",
      "鐘(1)",
      "鐘(2)",
      "鐘(3)",
      "お鈴"
    ]
  },
  .....
  "background": {
    "title": "背景",
    "type": [
      "森林",
      "蓮の花",
      "空",
      "星"
    ]
  },
  .....
}

i18n.js

Vue と vue-i18n プラグインをインポートします。

import Vue from 'vue';
import VueI18n from 'vue-i18n';

ブラウザの言語バージョン(window.navigator.language)からアプリで使用する言語ファイルを決定し、require() で読み込みます。

let locale = "en", message = {};    // default: "en"
const language = window.navigator.language;
if (language) {
  const lang = language.toLowerCase().split("-");
  switch (lang[0]) {
    case "ja":    // 日本語: ja, ja-JP
    case "es":    // スペイン語: es, es-*
    case "ko":    // 韓国語
    case "zhcn":  // 中国語 (簡体)
    case "zhtw":  // 中国語 (繁体)
      locale = lang[0];
      break;
    case "zh":    // 中国語: zh-*
      switch (lang[1]) {
        case "cn":  // 中国
        case "sg":  // シンガポール
        case "hans":
          locale = "zhcn";  // 中国語 (簡体)
          break;
        case "tw":  // 台湾
        case "hk":  // 香港
        case "mo":  // マカオ
        case "hant":
          locale = "zhtw";  // 中国語 (繁体)
          break;
      }
      break;
  }
}

message[locale] = require(`./assets/i18n/${locale}.json`)

vue-i18n のインストールおよびインスタンスの作成を行います。作成したインスタンスはエクスポートしておきます。

Vue.use(VueI18n);

export const i18n = new VueI18n({
  locale,
  messages: message   // 必要な言語ファイルのみ設定する
});

main.js

Vue と i18n.js からエクスポートされた vue-i18n インスタンスをインポートします。

import Vue from 'vue';
import { i18n } from './i18n.js'

Vue インスタンスを作成します。このとき Vue のオプションオブジェクトとして vue-i18n インスタンスを渡します。

new Vue({
  el: '#app',
  i18n,
  template: '<app></app>',
  components: { App },
  .....
});

Vue ファイル

テンプレートに直接 $t() メソッドを記述することができます。

<v-ons-list-header>{{ $t('sound.title') }}</v-ons-list-header>
<v-ons-list-item
  v-for="(soundType, $index) in $t('sound.type')"
  :key="soundType"
  tappable
>
  <label class="left">
    <v-ons-radio
      :input-id="'radio-sound-' + $index"
      :value="$index"
      v-model="tmp_soundType"
      @click="clickedSoundType($index)"
    >
    </v-ons-radio>
  </label>
  <label
    :for="'radio-sound-' + $index"
    class="center"
  >{{ soundType }}</label>
</v-ons-list-item>

$t() メソッドはスクリプトでも使うことができます。

const a = this.$t('background.type')
console.log(a)    // ["森林","蓮の花","空","星"]

これを応用して算出プロパティで使ってみました。this.state.backgroundType(文字列の0から3)の値に対応する文字が表示されます。

<div>{{ image }}</div>
computed: {
  image () {
    return this.$t(`background.type[${Number(this.state.backgroundType)}]`)
  }
}

また、vue-i18n のインスタンス作成時に設定した locale にアクセスすることができます。

onHelp () {
  window.open('http://senmyou.xyz/app/mnote/mnote_help_' + this.$i18n.locale, '_system');
},
onPrivacy () {
  if (this.$i18n.locale === "ja") {
    window.open('http://senmyou.xyz/app/mnote_privacy_ja', '_system');
  } else {
    window.open('http://senmyou.xyz/app/mnote_privacy_en', '_system');
  }
}