スマートフォンアプリの FF14ロードストーンチェッカー for FFXIV の開発で
Cordovaを使ったのですが、バックグラウンドフェッチに関してはいろいろなところで
躓いたので忘れないうちにまとめておこうと思います。
注:cordova-plugin-background-fetchが5.6.1の時の情報です。現在はバージョンアップされているため、情報が古くなっている可能性が高いです。
バックグラウンドフェッチとは
一定時間毎にアプリを起動していなくても何か処理を行う機能です。
新規ニュースやメッセージを取得したりするのに使います。
Android、iOSどちらも機能として持っています。
ただし、一定時間毎というのが曲者で1分毎に何かしたいって用途には使えません。
決めることができるのは最小実行間隔で、いつ実行されるかは端末の状況に左右されます。
iOSだと1日に1回しか実行されないこともあります。
詳細についてはグーグルとかで調べると色々出てきますが、
Androidの場合はデベロッパーガイドを参照するといいです。
バックグラウンドフェッチ実装に必要なプラグイン
FF14ロードストーンチェッカー for FFXIV でバックグラウンドフェッチを実装するのに
以下のプラグインを使用しました。
Cordovaのバックグラウンドフェッチ実装について調べると1番目のプラグインしか
使用していないのですが、後述する問題で残り2つのプラグインを使用しています。
問題が無ければ1番目のプラグインだけ入れればいいと思います。
バックグラウンドフェッチの実装
以下の様に実行オプションとバックグラウンドフェッチ時に行う処理を書きます。
let fetchCallback = function () { // バックグラウンドフェッチで行う処理を記載 } let failureCallback = function (error) { // バックグラウンドフェッチ失敗時の処理を記載 } // バックグラウンドフェッチの実行設定 BackgroundFetch.configure(fetchCallback, failureCallback, { minimumFetchInterval: (60), requiredNetworkType: BackgroundFetch.NETWORK_TYPE_UNMETERED, requiresDeviceIdle: true, stopOnTerminate: false, startOnBoot: true, forceReload: true })
バックグラウンドフェッチを停止する場合は以下を実行します。
BackgroundFetch.stop()
※実装方法の詳細はcordova-plugin-background-fetchのReadmeを参照
cordova-plugin-background-fetchはiOS、Android双方に対応しているので、
どちらのデバイスでもバックグラウンドフェッチが行われます。
あと、 cordova-plugin-background-fetchのconfigureメソッドのオプションで
説明が分かりづらかったオプションについて補足説明をします。
- forceReload
true:「stopOnTerminate」がtrue&アプリが終了している場合にバックグラウンドフェッチ処理を行うためにアプリを起動後最小化します。(アプリが画面に一瞬表示されます。)
false:「enableHeadlessがfalse」&アプリが終了している場合、バックグラウンドフェッチ処理は行いません。「enableHeadlessがtrue」&アプリが終了している場合、BackgroundFetchHeadlessTask.javaの処理を行います。
※Javaの処理を書きたくない場合、「true」にする必要があるのですが、後述のバグ&Androidの仕様変更の影響で有効(true)にしただけではうまく使えません。
- requiredNetworkType
この中の「BackgroundFetch.NETWORK_TYPE_UNMETERED」がWi-Fiを指しています。
- requiresDeviceIdle
trueの場合、デバイスがアクティブの場合にバックグラウンドフェッチを実行しないようにします。
forceReloadがtrueだとアプリが一瞬起動するため、他のアプリ操作中に画面が重ならないようにこのオプションを有効にしています。
※後述するAndroidの仕様変更の影響で有効(true)にすると、バックグラウンドフェッチが全く実行されなくなる可能性があります。
バックボタンでアプリが終了
Androidにはバックボタンがあるのですが、バックボタンを使うとアプリが終了します。
なので、バックグラウンドフェッチ実行のたびにアプリが起動して鬱陶しいことになります。(forceReloadがtrueの場合)
この問題に対処するために、cordova-plugin-background-modeのoverrideBackButtonメソッドを利用しました。
ただし、記載通りにやってもうまくいかないため、以下の様にバックボタン押下時の処理でエラーを発生させてアプリを終了しないようにしています。
onDeviceReady () { window.cordova.plugins.backgroundMode.overrideBackButton() document.addEventListener('backbutton', function (e) { window.cordova.plugins.backgroundMode.moveToBackground() // アプリが終了する問題を回避するための処理 throw new Error('app background') }, false) }
バックグラウンドフェッチでAjax通信ができない
FF14ロードストーンチェッカー for FFXIV では、バックグラウンドフェッチでAjaxを使って新着の日記一覧を取得しているのですが、Ajaxで通信をしようとするとAndroidでは処理が止まってしまいました。
ただし、アプリをバックグラウンドからフォアグラウンド(画面の前面)にすると止まっていた処理が動き始めます。
どうやら、アプリをバックグラウンドにしてから5分程経過するとAjax通信などの重い処理はAndroidのWebViewが処理を止めるようです。
この問題には cordova-plugin-background-modeのdisableWebViewOptimizationsメソッドを利用しました。
バックグラウンドフェッチの実行処理の中でこのメソッドを呼び出すと、処理が正しく実行されるようになりました。
let fetchCallback = function () { window.cordova.plugins.backgroundMode.disableWebViewOptimizations() // バックグラウンドフェッチで行う処理を記載 BackgroundFetch.finish() }
バックグラウンドフェッチで画面が起動したままになる
forceReloadをtrueにしていると、アプリが終了していた場合、アプリを起動&最小化するようになるのですが、アプリが起動したままになる問題がありました。
調べてみると、アプリが「メモリ開放状態」の場合に、cordova-plugin-background-fetchがアプリを再起動させる際に、正しくフラグを渡せていないことが原因でした。
まずアプリの終了状態は「メモリ開放状態」「完全終了状態」の2種類あるようで、
アプリをしばらく使っていないか、メモリ解放アプリを使用すると「メモリ開放状態」に移行し、さらに使用していない時間が長くなるか、アプリをスワイプして終了した場合に「完全終了状態」に移行するようです。
cordova-plugin-background-fetchの処理ではforceReloadがtrueの場合、起動時の引数に再起動フラグをセットしてアプリを起動することで、バックグラウンドフェッチの処理とアプリ最小化を行っています。
しかし、「メモリ開放状態」だと起動時の引数が読み取れず、処理が正しく行われないようです。
そのため、以下のように起動時の引数ではなくネイティブストレージにフラグを書き込んで読み込む様にプラグインの処理を修正しました。
BackgroundFetch.java(tsbackgroundfetch-0.3.3.aar内のソースです。)
public void forceMainActivityReload() { Log.i(TAG,"- Forcing MainActivity reload"); PackageManager pm = mContext.getPackageManager(); Intent launchIntent = pm.getLaunchIntentForPackage(mContext.getPackageName()); if (launchIntent == null) { Log.w(TAG, "- forceMainActivityReload failed to find launchIntent"); return; } // ▼▼ add SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mContext); SharedPreferences.Editor editor = prefs.edit(); editor.putBoolean("forcing_reload", true); editor.apply(); // ▲▲ launchIntent.setAction(ACTION_FORCE_RELOAD); launchIntent.addFlags(Intent.FLAG_FROM_BACKGROUND); launchIntent.addFlags(Intent.FLAG_ACTIVITY_NO_USER_ACTION); launchIntent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); mContext.startActivity(launchIntent); }
CDVBackgroundFetch.java
// ---- ▼▼ add import android.content.SharedPreferences; import android.preference.PreferenceManager; // ---- ▲▲ public class CDVBackgroundFetch extends CordovaPlugin { private static final String JOB_SERVICE_CLASS = "HeadlessJobService"; private boolean isForceReload = false; @Override protected void pluginInitialize() { Activity activity = cordova.getActivity(); // ---- ▼▼ mod //Intent launchIntent = activity.getIntent(); //String action = launchIntent.getAction(); //if ((action != null) && (BackgroundFetch.ACTION_FORCE_RELOAD.equalsIgnoreCase(action))) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity.getApplicationContext()); boolean force_flag = prefs.getBoolean("forcing_reload", false); if (force_flag) { SharedPreferences.Editor editor = prefs.edit(); editor.putBoolean("forcing_reload", false); editor.apply(); // ---- ▲▲ isForceReload = true; activity.moveTaskToBack(true); } }
Androidの最新バージョンでバックグラウンドフェッチが動かない
forceReloadをtrueにしていると、アプリが終了していた場合、アプリを起動&最小化するようになるのですが、Androidの最新バージョンではアプリが起動しない問題がありました。
これは、Androidの最新バージョン(10以降)ではバックグラウンドでアプリを起動する場合にSYSTEM_ALERT_WINDOW権限が必要になったことが影響していました。
このSYSTEM_ALERT_WINDOW権限が厄介でcordova-plugin-android-permissionsのプラグインでは権限の許可ができませんでした。
なので、 https://qiita.com/chibatching/items/add5e50921c2da5ee1c7を参考に、以下の様にプラグインを改修しました。
Permissions.java
// ---- ▼▼ add import android.net.Uri; import android.provider.Settings; import android.content.Intent; import android.app.Activity; // ---- ▲▲ /** * Created by JasonYang on 2016/3/11. */ public class Permissions extends CordovaPlugin { //~省略~ private void checkPermissionAction(CallbackContext callbackContext, JSONArray permission) { if (permission == null || permission.length() == 0 || permission.length() > 1) { JSONObject returnObj = new JSONObject(); addProperty(returnObj, KEY_ERROR, ACTION_CHECK_PERMISSION); addProperty(returnObj, KEY_MESSAGE, "One time one permission only."); callbackContext.error(returnObj); } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { JSONObject returnObj = new JSONObject(); addProperty(returnObj, KEY_RESULT_PERMISSION, true); callbackContext.success(returnObj); } else { try { JSONObject returnObj = new JSONObject(); // ---- ▼▼ mod // addProperty(returnObj, KEY_RESULT_PERMISSION, cordova.hasPermission(permission.getString(0))); if (permission.getString(0).equals("android.permission.SYSTEM_ALERT_WINDOW")) { addProperty(returnObj, KEY_RESULT_PERMISSION, Settings.canDrawOverlays(cordova.getActivity().getApplicationContext())); } else { addProperty(returnObj, KEY_RESULT_PERMISSION, cordova.hasPermission(permission.getString(0))); } // ---- ▲▲ callbackContext.success(returnObj); } catch (JSONException e) { e.printStackTrace(); } } } private void requestPermissionAction(CallbackContext callbackContext, JSONArray permissions) throws Exception { if (permissions == null || permissions.length() == 0) { JSONObject returnObj = new JSONObject(); addProperty(returnObj, KEY_ERROR, ACTION_REQUEST_PERMISSION); addProperty(returnObj, KEY_MESSAGE, "At least one permission."); callbackContext.error(returnObj); } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { JSONObject returnObj = new JSONObject(); addProperty(returnObj, KEY_RESULT_PERMISSION, true); callbackContext.success(returnObj); // ---- ▼▼ add } else if (permissions.length() == 1 && permissions.getString(0).equals("android.permission.SYSTEM_ALERT_WINDOW")) { JSONObject returnObj = new JSONObject(); Activity activity = cordova.getActivity(); Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + activity.getPackageName())); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); activity.getApplicationContext().startActivity(intent); addProperty(returnObj, KEY_RESULT_PERMISSION, true); callbackContext.success(returnObj); // ---- ▲▲ } else if (hasAllPermissions(permissions)) {
これで、 hasPermissionメソッドで権限の有無を確認、requestPermissionメソッドで権限の許可画面を起動できるようになります。
requiresDeviceIdle有効時、バックグラウンドフェッチが行われない
requiresDeviceIdleが有効(true)の場合、 デバイスがアクティブの場合にバックグラウンドフェッチを実行しないようになりますが、端末を普通に使っている場合はバックグラウンドフェッチが全く行われない事象が発生します。
原因がはっきりと分かっていないですが、AndroidのDozeモードが影響しているようです。
このDozeモードはデバイスが非アクティブになって一定時間経つとモード移行し、一定時間が経つかデバイスがアクティブになったときにまとめてバックグラウンドフェッチが行われる(maintenance window)機能のようです。
この機能とrequiresDeviceIdleが組み合わさった時、上の赤マーカーした箇所が問題で一定時間経つ前にアクティブになると、デバイスがアクティブなのでさらにフェッチが先延ばしにされてしまい、端末を普段使っていると全くフェッチが行われない事象が発生するようです。
このため、 cordova-plugin-background-modeのdisableBatteryOptimizationsメソッドを使用して、バッテリーの最適化を無効にし、Dozeモード中でもフェッチが行えるようにすることで解決しました。
謎のバッテリー消費
cordova-plugin-background-modeを入れて、いろいろやってるうちにアプリを
起動してないにも関わらず、常にバッテリーを消費し続けるようになりました。
原因を探ってみるとbackgroundMode.setEnabled(true)を実行したことが原因のようでした。
どうやらバックグラウンドモードを有効にすると、常に無音の曲を流し続けるように
なるようです…(過去にFaceBookのアプリでもやってた方法みたいです。)
とりあえず、バックグラウンドフェッチをする分には有効にする必要は無かったので、
backgroundMode.setEnabled(true)を削除することで解決しました。
Apple Store審査でリジェクト
ついに完成!とApple Storeの審査に出したら見に覚えのないことでリジェクト
詳細はもう見れないのですが、このアプリに必要なさそうなバックグラウンドモードの
「 Audio, AirPlay and Picture in Picture 」がONになっているのでOFFにしてくれという
内容だったかと思います。
自分で設定したわけでもないのになぜと原因を探ってみたら、
cordova-plugin-background-modeが勝手にONにするようでした。
なので、XcodeでOFFにしてコンパイル&再審査に出してアプリをリリースしました。
アプリを起動すると再生中の音楽が停止
アプリを起動するとバックグラウンドで再生中の音楽が停止する問題がありました。
色々調べてみると有力な情報を見つけることが出来ました。
cordova-plugin-background-modeのバグで問題が起きているとのことです。
有志の方が修正したプラグインの修正箇所を自分のアプリに組み込み、問題が解決しました。
最後に
とりあえず、これら出てきた問題は一通り解決してうまくバックグラウンドフェッチができるようになりました。
cordova-plugin-background-fetchのバージョンが上がっているようなので情報として古いですが、参考になればと思います。