20200322のAndroidに関する記事は9件です。

【Android】Fragment間で値をやりとりする

はじめに

あるFragmentで取得した値を、他のFragmentでも使いたい場面は多々あるかと思います。
この記事では、Fragment間で値をやりとりする方法をまとめました。

方法一覧

Fragment間で値をやりとりする方法は、ざっくり分けて次のとおりです。

  • Bundleを使う
    • 【応用】SafeArgsを使う
  • ActivityのスコープでViewModelを使う
  • navGraphViewModelsを使う

抜けがあったら教えてください。

次に、それぞれのやり方について解説していきます。
※番外編としてViewPagerやBottomNavigationのページ間で値をやりとりする方法についても解説します。

Bundleを使う

一番オーソドックスなやり方です。遷移先のFragmentを生成する際に値をargumentsとして渡します。

やり方

以下のようにして遷移先のFragmentに値を渡します。

val title = "タイトル"
// Bundleインスタンスを作成
val bundle = Bundle()
// putXXXXで値をセットする
bundle.putString("BUNDLE_KEY_TITLE", title)
// Fragmentに値をセットする
val fragment = SecondFragment()
fragment.arguments = bundle
// 遷移処理
parentFragmentManager.beginTransaction()
        .add(R.id.container, fragment)
        .commit()

値を受け取る側はgetArguments(arguments)で値を取得します。

SecondFragment
// putXXXXに対応するgetXXXXで値を取得
val args = arguments?.getString("BUNDLE_KEY_TITLE") // "タイトル"

こういう時に使える

Bundleを使った方法は、単方向の値のやりとりに向いています。

スクリーンショット 2020-03-23 10.23.40.png

Bundleで渡した値は読み取り専用のため、受け取った値を書き換えたり、加工した値をまたFragmentAで使いたい場合には向いていません。
(varで値を取得して書き換えてまたBundleに渡して…というやり方も出来なくはないですが、煩雑になるのでオススメは出来ません。おとなしくViewModelを使いましょう)

【応用】SafeArgsを使う

Bundleで渡した値を型安全に使えるSafeArgsというものが登場しました。

bundle.putXXXXarguments.getXXXX で値をやりとりするのは便利ではありますが、渡すキー名を間違えると NULL が返ってくるため、簡単にクラッシュしてしまいます。
それをプロパティを介したアクセスにする事で、型安全にアクセス可能になります。

やり方

Gradleに依存関係は追加済みとします。

SafeArgsはNavigation ComponentのNavigation Graphを使用します。
Navigation Graphに argument タグを追加します。これは遷移先のFragmentタグ内に追加します。

nav_graph.xml
    <fragment android:id="@+id/firstFragment"
        ...>
       <action
            android:id="@+id/action_first_to_second"
            app:destination="@id/secondFragment"/>
    </fragment>
    <fragment android:id="@+id/secondFragment"
        ...>

        <argument
                android:name="title"
                app:argType="string"/>

    </fragment>

argument タグ追加後は一度ビルドしておきましょう。

SecondFragment遷移時に以下のように引数を渡します。

FirstFragment
val title = "タイトル"
val action = FirstFragmentDirections.actionFirstToSecond(title)
findNavController().navigate(action)

遷移後のFragmentで値を取り出すには navArgs() を使います。

SecondFragment
class SecondFragment : Fragment() {
    private val args: SecondFragmentArgs by navArgs()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        textView.text = args.title // プロパティでアクセス可能
    }
}

Navigation Componentを使った遷移を使わない場合でも、SafeArgsだけを使用することが可能です。
Navigation Graphへの定義までは同じで、そのあとは以下のように使えます。

FirstFragment
val bundle = SecondFragmentArgs.Builder(title)
    .build()
    .toBunble()
val fragment = SecondFragment()
fragment.argument = bundle
// 遷移処理はBundleと同じなので省略

値の取り出し方はNavigation Componentを使った場合と同じです。

SecondFragment
private val args: SecondFragmentArgs by navArgs()

ActivityのスコープでViewModelを使う

読んで字のとおりですが、ActivityのスコープでViewModelを定義し、それをFragment間で使いまわします。公式でも紹介されているやり方です。

やり方

例えばMainViewModelがあったとします。

MainViewModel
class MainViewModel: ViewModel()

これをFragmentで取得する際、Activityを引数に渡します。

FirstFragment
class FirstFragment: Fragment() {

    lateinit var viewModel: MainViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
    activity?.run {
            viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
        }
    }
}

これでViewModelがActivityのViewModelStore(※)に保持されるので、他のFragmentでも同じViewModelのインスタンスを利用できます。

(※)ViewModelStore:ViewModelを保持するクラス

もしくはFragment-KTXを利用すれば、 activityViewModels でもViewModelのスコープをActivityにできます。

FirstFragment
private val viewModel: MainViewModel by activityViewModels()

(独自のViewModelStoreOwnerを定義して利用する方法もあるようですが、ここでは割愛します。こちらの記事が参考になりそうです)

こういう時に使える

ViewModelを使った方法は、双方向の値のやりとりに向いています。
スクリーンショット 2020-03-23 10.39.07.png

FragmentAでもFragmentBでも値を参照し、かつ、どちらのFragmentでも値の変更が行われる可能性がある場合などは、BundleよりもViewModelを使った方が処理がスマートになると思います。

navGraphViewModelsを使う

Navigation ComponentFragment-KTXを組み合わせることによって、Navigation Graph単位のスコープを持ったViewModelを使うことが出来ます。
(ネストしたNavGraphでしか使えず、メインのNavGraphでは使えない事に注意が必要です)

やり方

NavigationGraphの中に、以下のようなネストしたNavGraphとFragmentを定義します。
(説明のために色々端折っています)

nav_graph.xml
    <navigation android:id="@+id/nested_nav_graph"
        app:startDestination="@id/secondFragment">
        <fragment android:id="@+id/secondFragment">
        ...
        <fragment android:id="@+id/thirdFragment">
    </navigation>

Fragment上で以下のようにViewModelを取得します。

SecondFragment
class SecondFragment: Fragment() {
    private val viewModel: MainViewModel by navGraphViewModels<R.id.nested_nav_graph>()
}
SecondFragment
class ThirdFragment: Fragment() {
    private val viewModel: MainViewModel by navGraphViewModels<R.id.nested_nav_graph>()
}

これでSecondFragmentとThirdFragmentは共通のViewModelインスタンスを使えるようになります。
このViewModelはNavGraph単位のスコープを持つので、別のNavGraphに遷移した場合は値が破棄されます。

もっと詳しい情報はSTAR-ZEROさんのこちらの記事が参考になります。

こういう時に使える

ActivityスコープのViewModelは、SingleActivityのアプリだと、実質どこのFragmentからでもアクセス出来てしまいます。
それではスコープが大きすぎる、という時はnavGraphViewModelsを使ってスコープ範囲を分割していくと良いかと思います。

番外編: ViewPagerやBottomNavigationViewの子ページ同士で値をやりとりする

ViewPagerやBottomNavigationViewを使っている時、ページ間で値のやりとりをしたい場合は、BundleやnavGraphViewModelsは使えません(たぶん)。

方法はいくつかあるかと思いますが、個人的にはViewModelProviderに parentFragment を渡して、ViewModelを共有するのが一番やりやすいと思います。

ChildFragment
    lateinit var viewModel: MainViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // parentFragmentを渡す
        viewModel = ViewModelProvider(parentFragment).get(MainViewModel::class.java)

    }

こうすることでViewModelのインスタンスが親FragmentのViewModelStoreに保持されるため、ページ間で共通のViewModelインスタンスを利用することが可能になります。

おわりに

本記事を書くにあたって、以下のリンク先を参考にさせて頂きました。
[Qiita] 【Kotlin】Bundleを使ったFragment間の値渡し
[Qiita] [Android] NavigationでSafeArgsを使って引数付き画面遷移をする
ViewModel、ViewModelProviderについて調べてみた(Android)
[Qiita] Activity, Fragmentを跨いでViewModelを共有する
Navigation GraphスコープのViewModel

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Build Valiant毎に使用するkeystoreを変更する

Flavor毎にkeystoreを変更する方法は見かけるのですが、Flavor と Build Type毎にkeystoreを分ける方法がなかなか見つかりませんでした。
以下のようにproductFlavors.{flavor名}.signingConfigsの中にdebug, releaseを書けば、flavor と build typeそれぞれで使用するkeystoreを変更することができます。

productFlavors {
  hoge {
    dimension = 'market'
    signingConfigs {
      debug {
        ...
      }
      release {
        ...
      }
    }
  }
}

参考
https://stackoverflow.com/questions/30898611/gradle-signing-flavors-with-different-keys-on-android

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Flutterでflutter_app_badgerを使い、アプリのホームアイコンにバッジを表示する

Flutterでアプリ開発をしている村松龍之介と申します。
(仕事ではiOSアプリのネイティブアプリ開発を行っています)

今回は、FlutterアプリにiPhoneやiPadではお馴染みのアイコンバッジを付けるためにパッケージを導入しましたので備忘録です。

flutter_app_badgerパッケージを導入する

簡単にアイコンバッジを付けられるパッケージがありますので、導入します。
flutter_app_badger | Flutter Package

pubspec.yamlを編集

dependencies:
    # 〜〜省略〜〜
  flutter_app_badger: ^1.1.2 # 導入時点で最新のバージョンを指定でOKだと思います。

iOS

iOSでバッジを表示するためには権限が必要なので以下をinfo.plistに追記します。

Android StudioやVS Codeでinfo.plistを開いて編集する場合

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- ここから下を追加 -->
    <key>UIBackgroundModes</key>
    <array>
        <string>remote-notification</string>
    </array>
    <!-- ここまで -->
</dict>
</plist>

Xcodeでinfo.plistを(Property Listとして)開いて編集する場合

Required background modesを追加して、その一要素としてApp downloads content in response to push notificationsを追加します。

Android

On Android, no official API exists to show a badge in the launcher. But some devices (Samsung, HTC...) support the feature. Thanks to the Shortcut Badger library , ~ 16 launchers are supported.
(翻訳)Androidには、ランチャーにバッジを表示する公式のAPIはありません。ただし、一部のデバイス(Samsung、HTC ...)はこの機能をサポートしています。 Shortcut Badgerライブラリの おかげで、 最大 16個のランチャーがサポートされます。

Androidの場合は、対応する機種が限られますが、特にやらなきゃいけないこともないようです。
ちなみにPixel 3 Xlではバッジは表示されませんでした。

コード実装

バッジの表示・数字の更新

実装は思いのほか簡単です。
intを引数にして以下の関数を呼び出すことでバッジを表示できます。

FlutterAppBadger.updateBadgeCount(1);

上記の例では「1」を渡しているので、ホーム画面のアイコンに❶というようにバッジが表示されます。

doubleの場合はintに変換してから引数に渡しましょう。

0以下の数値を渡すことでバッジが表示されないことを確認しました。
マイナスの数値を渡してもバッジが表示されません。

バッジをサポートしている環境か調べる

Androidでは一部のデバイスしか対応していないこともあり、あらまじめバッジ機能をサポートしているか調べた方が良いですね。

FlutterAppBadger.isAppBadgeSupported();

上記の関数を実行することで、非同期で真偽値(bool)が返却されます。

上記の実装をメソッドにするなら以下のような感じでしょうか?

  /// 1以上の数値を渡すことでホーム画面のアイコンにバッジを表示する
  void setIconBadge({int number}) async {
    // バッジ表示機能に対応している場合のみ、バッジの数字を更新する
    if (await FlutterAppBadger.isAppBadgeSupported()) {
      FlutterAppBadger.updateBadgeCount(number ?? 0); // <-引数の`number`が`null`だった場合は`0`
    }
  }

iOSの場合、許可を得る必要がありますが、バッジの初回更新時に表示してくれます。

バッジを非表示にしたいとき

0以下の数値で更新することもできますが、意図的にバッジを非表示にしたいときは以下の関数でバッジが消した方が良さそうです。

FlutterAppBadger.removeBadge();

呼び出す場所

基本的に好きな場所で呼び出して良いかと思います。
僕の場合は、アプリのメイン画面で有給休暇の残日数を計算しており、バッジでその残日数を表示させたかったので、計算結果を引数に入れて使用しました。

蛇足

ご覧いただきありがとうございました!
蛇足ですが、Flutterアプリをリリースできたので良かったらインストールしてみてもらえると嬉しいです?‍♂️
iOS: ‎「レストル-有給休暇管理」をApp Storeで
Android: レストル-有給休暇管理 - Google Play のアプリ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ExpoでAPKをビルド後にAndroidManifest.xmlをいじってみる

やりたいこと

ExpoのデフォルトのAndroidManifest.xmlではapplicationのandroid:allowBackuptrueで、つまり自動バックアップが有効になっています。
訳あってこれをfalseにしたい。
しかしExpoのManaged WorkflowではiOSのinfoPlistのようにはAndroidManifest.xmlの詳細項目を直接設定することはできないようで、Feature requestsとしてもいくつか挙がっています。
https://expo.canny.io/feature-requests?search=allowbackup

そのため、ビルド後のAPKファイルを展開してAndroidManifest.xmlを編集、再ビルドという手順で無理矢理いじってみることにしました。Javaのソースコードはともかく、マニフェストファイルくらいは簡単に書き換えられるだろうということで。
あまり聞こえのいい感じはしませんが、とりあえずできるかどうか試してみたという感じです。

まずはallowBackup="true"の挙動を確認

こんな感じで入力したテキストを保存するような画面を簡単に作ってみます。
バックアップはAsyncStorageで保存したデータなども含むはず。

App.js
import React, { useState, useEffect } from 'react';
import { StyleSheet, View, AsyncStorage, Button, TextInput } from 'react-native';

export default function App() {
  const [text, setText] = useState('');

  useEffect(() => {
    AsyncStorage.getItem('text').then(setText);
  }, []);

  const saveText = () => {
    AsyncStorage.setItem('text', text);
  };

  return (
    <View style={styles.container}>
      <TextInput style={styles.input} value={text} onChangeText={setText} />
      <Button title="Save" onPress={saveText} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  input: {
    backgroundColor: 'lightgray',
    width: 200,
    height: 40,
    marginBottom: 16
  }
});

app.jsonにパッケージ名を設定したうえでAndroid向けにビルドし、Android端末で確認してみます。

何かしら入力したあと「SAVE」ボタンを押し、AsyncStorageに保存させます。再度起動しても同じ文字列が表示されるようになるはずです。

この状態で、adbを使ってバックアップをしてみます。
この辺りのドキュメント記事を参考にしています。)

$ adb shell setprop log.tag.BackupXmlParserLogging VERBOSE
$ adb shell bmgr run
$ adb shell bmgr backupnow <パッケージ名>

バックアップができたら、先ほど入力した文字列を消して「SAVE」し、

再度起動しても入力した文字列が消えているのを確認します。

ここで先ほどバックアップした状態を復元してみます。
バックアップを特定するためのトークンを確認するコマンドを叩きましょう。

$ adb shell dumpsys backup

ずらっと出てきた中に、Current: ~~とあるのがバックアップトークンです。よほど時間が経っていない限りこれが先ほど行ったバックアップのものなので、

$ adb shell bmgr restore <バックアップトークン> <パッケージ名>

こんな感じで叩けば復元が完了するはずです。

再度アプリを開いてみると、文字列が復活しました。
バックアップした時のAsyncStorageのデータが復元されているのがわかります。

これでallowBackup="true"が正常に効いているのがわかりました。

ビルド後のAPKをいじってみる

ビルド後のAPKを展開し、allowBackup="false"に修正してみます。
APKの展開には、apktoolを使用しますが、Expoの開発フローとある程度調和させたいなとか、インストールが面倒だなということで、Node.jsでシェルスクリプトっぽいのを書いてみました。
.jarのライブラリファイルをそのまま含むnpmパッケージがあるので、これを使います。

パッケージのインストール
$ yarn add --dev child_process apktool-jar replace

ExpoでビルドしたAPKファイルを、ディレクトリを切ってdist/app.apkとしてプロジェクトディレクトリに移動するような感じを想定しています。

editAndroidManifestXML.js
const childProcess = require('child_process');
const apktool = require('apktool-jar');
const replace = require('replace');

const directory = 'dist/'; // APKを入れるディレクトリ
const srcApkName = 'app.apk'; // 元のAPKファイル名
const distDirectory = 'dist/app/'; // 展開するディレクトリ
const revisedApkName = 'app-revised.apk'; // 再ビルドしたAPKファイル名
const distApkName = 'app-release.apk'; // 最適化・署名したAPKファイル名
const keystorePath = '/Users/.../.android/test.keystore'; // keystoreのパス
const keyAlias = 'TEST'; // keystoreのエイリアス
const keyPass = 'password'; // keystoreのパスワード

/**
 * spawnをpromise化
 * @param {string} command
 * @param {Array<string>} args
 * @returns {Promise<any>}
 */
const spawn = (command, args) => (
  new Promise((resolve)=>{
    const process = childProcess.spawn(command, args);
    process.stdout.on('data', (data) => console.log(`${data}`));
    process.stderr.on('data', (data) => console.log(`${data}`));
    process.on('exit', resolve);
  })
);

(async () => {
  // apktoolでAPKを展開
  await spawn('java', ['-jar', apktool.path, 'd', `${directory}${srcApkName}`, '-o', distDirectory, '-f']);

  // 展開されたAndroidManifest.xmlをいじる
  // allowBackupをfalseに変更
  replace({
    regex: 'android:allowBackup="true"',
    replacement: 'android:allowBackup="false"',
    paths: [`${distDirectory}AndroidManifest.xml`],
    silent: true
  });
  console.log('Replaced: allowBackup="true" ==> allowBackup="false"');

  // apktoolでAPKを再ビルド
  await spawn('java', ['-jar', apktool.path, 'b', distDirectory, '-o', `${directory}${revisedApkName}`, '-f']);

  // zipalignでAPKを最適化
  await spawn('zipalign', ['-f', '-v', '4', `${directory}${revisedApkName}`, `${directory}${distApkName}`]);

  // apksignerでAPKに署名
  await spawn('apksigner', ['sign', '--ks', keystorePath, '-v', '--v2-signing-enabled', 'true', '--ks-key-alias', keyAlias, '--ks-pass', `pass:${keyPass}`, `${directory}${distApkName}`]);

  // 署名を確認
  await spawn('apksigner', ['verify', '--print-certs', '-v', `${directory}${distApkName}`]);
})();

Expoでは署名も勝手にやってくれますが、再ビルドした後はkeystoreを用意し署名したりを自分でやらないといけません。
すでにあるものや、作成するなどして任意のkeystoreとエイリアスを使うようにコードは修正してください。
スクリプトの処理は

  1. apktoolでAPKを展開
  2. 展開されたファイルを編集
  3. apktoolでAPKを再ビルド
  4. zipalignでAPKを最適化
  5. apksignerでAPKに署名

という流れになっています。

$ node editAndroidManifestXML.js

で実行すると、dist/appディレクトリにAPKが展開され、その中のAndroidManifest.xmlが書き換わります。

allowBackup="false"の挙動を確認

全て問題なく処理が終わり、dist/app-release.apkが生成されたら、これを確認してみます。
先ほどAndroid端末にインストールした元のアプリをアンインストールし、修正後のアプリをインストールします。

その後、バックアップを実行してみると、

$ adb shell bmgr backupnow <パッケージ名>
Running incremental backup for 1 requested packages.
Package <パッケージ名> with result: Backup is not allowed
Backup finished with result: Success

Backup is not allowedと表示され、allowBackup="false"の設定変更が効いているのが確認できました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AsyncTaskが実行されない不具合の解析

Androidのアプリ開発でAsyncTaskが実行されない不具合があり、解析した際の解析方法をメモします。

AsyncTaskが実行されない不具合

原因は、AsyncTask(executeOnExecutor(THREAD_POOL_EXECUTOR))で実行した、タスクの処理が想定していたより多くの時間がかかったことで、poolのthreadが全て実行状態となり、新しいタスクが実行されていませんでした。

解析方法

再現性の低い不具合だったため、発生時のBugreportで解析を行いました。
※やはり、不具合発生時はバグレポートを取っておいた方が良いですね。

下記は、不具合が発生したコードを擬似的に再現したものです。
AsyncTaskのexecuteOnExecutor(THREAD_POOL_EXECUTOR)でタスクを実行していますが、sleepを入れて処理に時間がかかるようにしています。

Sample.kt
        Log.d(TAG, "CPU_COUNT : " + Runtime.getRuntime().availableProcessors())
        for (i in 1..3) {
            val task = object : AsyncTask<Void, Void, Void>() {
                override fun doInBackground(vararg params: Void): Void? {
                    Log.d(TAG, ("doInBackground start " + i))
                    Thread.sleep(100000)
                    Log.d(TAG, "doInBackground end " + i)
                    return null
                }
            }
            task.executeOnExecutor(THREAD_POOL_EXECUTOR)
        }

下記は、上記のコードを実行した際のlogcatです。

2020-03-20 19:05:08.828 10486-10486/com.ykato.sample D/MainActivity: CPU_COUNT : 2
2020-03-20 19:05:08.839 10486-11079/com.ykato.sample D/MainActivity: doInBackground start 1
2020-03-20 19:05:08.841 10486-11080/com.ykato.sample D/MainActivity: doInBackground start 2
2020-03-20 19:06:48.848 10486-11080/com.ykato.sample D/MainActivity: doInBackground end 2
2020-03-20 19:06:48.849 10486-11080/com.ykato.sample D/MainActivity: doInBackground start 3
2020-03-20 19:06:48.874 10486-11079/com.ykato.sample D/MainActivity: doInBackground end 1
2020-03-20 19:08:28.862 10486-11080/com.ykato.sample D/MainActivity: doInBackground end 3

CPU数が2の環境で実行したため、thread poolサイズが2となります。
※thread poolサイズはこちらを参照してください。
そのため、タスク3の実行が待たされてしまいます。

タスク3が待たされている間に取得したBugreportのスタック トレースは下記のようになっています。
プロセスID10486のスレッドID11079、11080が実行中であることが分かります。
また、どこで止まっているかも分かります。
 MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:21)

------ VM TRACES JUST NOW (/data/anr/dumptrace_64OkJO: 2020-03-20 19:05:26) ------
〜〜省略〜〜
----- pid 10486 at 2020-03-20 19:05:25 -----
Cmd line: com.ykato.sample
〜〜省略〜〜
"AsyncTask #1" prio=5 tid=16 Sleeping
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x12f4a558 self=0xd0186600
  | sysTid=11079 nice=10 cgrp=default sched=0/0 handle=0xce41a970
  | state=S schedstat=( 928855 3381136 1 ) utm=0 stm=0 core=0 HZ=100
  | stack=0xce317000-0xce319000 stackSize=1042KB
  | held mutexes=
  at java.lang.Thread.sleep(Native method)
  - sleeping on <0x0b069882> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:373)
  - locked <0x0b069882> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:314)
  at com.ykato.sample.MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:21)
  at com.ykato.sample.MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:18)
  at android.os.AsyncTask$2.call(AsyncTask.java:333)
  at java.util.concurrent.FutureTask.run(FutureTask.java:266)
  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
  at java.lang.Thread.run(Thread.java:764)

"AsyncTask #2" prio=5 tid=17 Sleeping
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x12f4a778 self=0xd0186c00
  | sysTid=11080 nice=10 cgrp=default sched=0/0 handle=0xce314970
  | state=S schedstat=( 298657 4820846 1 ) utm=0 stm=0 core=1 HZ=100
  | stack=0xce211000-0xce213000 stackSize=1042KB
  | held mutexes=
  at java.lang.Thread.sleep(Native method)
  - sleeping on <0x0ad2bb93> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:373)
  - locked <0x0ad2bb93> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:314)
  at com.ykato.sample.MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:21)
  at com.ykato.sample.MainActivity$onCreate$task$1.doInBackground(MainActivity.kt:18)
  at android.os.AsyncTask$2.call(AsyncTask.java:333)
  at java.util.concurrent.FutureTask.run(FutureTask.java:266)
  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
  at java.lang.Thread.run(Thread.java:764)

補足

今回は、Bugreportから原因が判明しましたが、Bugreportは取るタイミングによって結果が変わるので、threadの処理にはlogを入れておいた方が良いですね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlin coroutine入門 ③~ViewModelの中でcoroutineを扱ってみる~

Kotlin coroutine入門 ②の続きです。

前回の入門記事から、時間が経っていますが、今回はViewModelの中で、何らかのAPIと通信する体のcoroutineを実装しUIに反映して行きたいと思います!!

従来の構成 MVVMRxとcoroutine比較

Rx使用時

スクリーンショット 2020-03-22 1.11.50.png

coroutine使用した場合

observableがまるっとcoroutinesに置き換わります。実際はReactiveStreamに似た、Coroutine flowでしょう。
スクリーンショット 2020-03-22 1.12.02.png

実装

実際に実装をしてみます。(Repositry,ApiClientはApi.ktとして実装しています)

Api.kt
package ***

import kotlinx.coroutines.*

class Api(){
     suspend fun isHoge (): Deferred<Boolean> = coroutineScope {
            async (context = Dispatchers.IO) {
                true
            }
     }
}
MainActivityViewModel.kt
package ***

import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext

class MainActivityViewModel() : ViewModel(), CoroutineScope, LifecycleObserver {


    private val job = Job()

    // Dispatcher.Main を指定しているためこのスコープで起動するコルーチンはメインスレッドで動作する。
    // Job として上で定義した job を渡しているので、すべてのコルーチンはこの job の子になる。
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job

    val scope = CoroutineScope(coroutineContext)

    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    fun onResume() {
        val api  = Api()
        //main threadで実行する
        scope.launch {
            try {
                api.isHoge().await().let {
                    Log.d("MainActivityViewModel", it.toString())
                }
            }catch (e: Throwable){
                Log.d("MainActivityViewModel", e.message)
            }
        }
        Log.d("MainActivityViewModel","onResumeEnd")
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
    fun onPause() {
        //jobに属している全てのtaskを停止する
        job.cancel()
    }
}

Jobキャンセルについて

親ジョブがキャンセルされると子Job は子にキャンセルを伝播します。
キャンセルを忘れると、リークする可能性があります。

MainActivityViewModel.kt
    @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
    fun onPause() {
        //jobに属している全てのtaskを停止する
        job.cancel()
    }

こちらが参考になりました。

ViewModel KTX

ViewModel KTXの viewModelScope() により、Coroutine Scopeやキャンセルが不要となりました。
実際のコードは以下の通りです。

(app)build.gradle
 dependencies {
        implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
    }

MainActivityViewModel.kt
package  ****

import android.util.Log
import androidx.lifecycle.*
import kotlinx.coroutines.launch


class MainActivityViewModel() : ViewModel(), LifecycleObserver {

    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    fun onResume() {
        val api  = Api()
        //main threadで実行する
        Log.d("MainActivityViewModel","onResumeStart")
        viewModelScope.launch {
            try {
                api.isHoge().await().let {
                    Log.d("MainActivityViewModel", it.toString())
                }
            }catch (e: Throwable){
                Log.d("MainActivityViewModel", e.message)
            }
        }
        Log.d("MainActivityViewModel","onResumeEnd")
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
    fun onPause() {
        //job cancel は不要
    }
}

コードもすっきりしましたし、cancelが漏れることもないでしょう。

まとめ

viewModelScopeが登場しキャンセル漏れも防げるようになりましたね。
この辺りはRxの時もDispose忘れなどあったので、すごく助かります。
次回はRxのReactiveStreamでおなじみである、coroutine channel flowについて
記載します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

checkSelfPermissionがPERMISSION_DENIEDしか返してくれない罠を踏み抜いた話

すでにリリース済みのアプリに対して某広告系ライブラリを入れた時に突如パーミッション関係が軒並みDENIEDを返すようになりパニックになったので自分のための備忘録として。

現象

checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)

もしくはその後、requestPermissionをコールした後にonRequestPermissionsResultで戻ってきた後の第三引数が全部PERMISSION_DENIEDになる。
ちなみに勿論パーミッションは許可された状態で起きる。
今回の場合はすでにリリース済みのアプリで、このあたりは元々元気に動いていたのに急にスンッ・・・とおかしくなったので事態の把握は楽でした。

原因と対応

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> 

AndroidManifest.xml で定義しているpermissionは上記のような定義だったのだけれど・・・
Merged Manifestのほうで確認してみると
2020-03-22_12h27_01.png

/(^o^)\・・・
そう。ライブラリ側でuses-permissionmaxSdkVersionが有り難くも設定されていたのでそもそもmaxSdkVersionを超えるSDKバージョンの場合はuses-permissionの定義は無かったことになる。
すなわちcheckSelfPermissionがDENIEDなのは当然の結果だった・・・

ということで、

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="replace" />

node="replase"指定を追加して、有り難く設定されているライブラリ側のmaxSdkVersionは無視させて頂くことにした。

教訓

新しいライブラリをリリース済みアプリに入れる時は、Merged Manifestがどうなっているかちゃんとチェックしようね!ぜったいだよ!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Android で Firebase In-App Messaging が表示されない問題の workaround

はじめに

Android で Firebase In-App Messaging を表示させてようとしても、アプリの作り次第では、 表示されない or すぐに消えてしまう問題 が発生することが確認されました。
全てのケースで解決しないかも知れませんが、意図通りに表示できる workaround を見つけたので記しておきます。

Firebase In-App Messaging 導入時の参考になれば幸いです。

※調査した技術内容が多めです
※キャンペーン情報の取得完了のタイミングや、メッセージを表示したい Activity の lifecycle の状態次第では上手く行かないケースが存在するかも知れません
→ 技術的背景を理解した上で、最適な workaround を使うことをオススメします。

本記事での用語

用語 意味
LaunchActivity AndroidManifest.xmlandroid.intent.category.LAUNCHER が指定されている Activity
MainActivity LaunchActivity から起動される Activity
In-App Message Firebase In-App Messaging が表示するメッセージ
(com.google.firebase.inappmessaging.model.InAppMessage というクラスがあります)

再現条件

In-App Message を表示した後、何らかの Activity(最前面になくても良い)が destroy されると、表示されている In-App Message が消える。
(タイミングによっては、表示されたことに気付けない)

ありがちな例

LaunchActivity が、 onCreate で他の Activity を呼び出して、 LaunchActivity は即閉じるような場合には再現します。

実装例

class LaunchActivity : AppCompatActivity() {

    // Firebase In-App Messaging SDK はこの Activity にメッセージを表示させようとするが、
    // すぐに画面遷移してしまうため、メッセージが表示されない(or すぐ消える)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_launch)

        if (isLogin) {
            openMainActivity()
        } else {
            openLoginActivity()
        }
        // ...
    }

    private fun openMainActivity() {
        val intent = Intent(this, MainActivity::class.java)
        //finish() で閉じなくても、↓でも再現
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
        startActivity(intent)
    }
}

なぜ消えるのか?

Firebase In-App Messaging の内部では、 Application.ActivityLifecycleCallbacks を実装したクラス( com.google.firebase.inappmessaging.display.FirebaseInAppMessagingDisplay )が、アプリケーション内の Activity の Lifecycle を監視して、In-App Message の制御を行っています。

onActivityDestroyed の override 実装を見ると、
https://github.com/firebase/firebase-android-sdk/blob/master/firebase-inappmessaging-display/src/main/java/com/google/firebase/inappmessaging/display/FirebaseInAppMessagingDisplay.java#L222-L228

@Override
public void onActivityDestroyed(Activity activity) {
  // clear all state scoped to activity and dismiss fiam
  headlessInAppMessaging.clearDisplayListener();
  imageLoader.cancelTag(activity.getClass());
  removeDisplayedFiam(activity);
  super.onActivityDestroyed(activity);
}

のようになっており、 Activity が destroy されたら、 removeDisplayedFiam(...) が呼び出されています。
FiamFirebase In-App Messaging のことです

特筆すべきは、最前面にある Activity かどうかは考慮していないということです。
(つまり、最前面にない Activity が destroy されることを SDK 側が考慮できていません)

workaround

MainActivity にて、 LaunchActivity が onDestroy されるまで In-App Messaging の表示を遅らせる。

と、端的に言っても、いくつかステップがあります。

  1. In-App Message の表示を抑制しておく
  2. MainActivity にて、 LaunchActivity が onDestroy されたことを検出する
  3. In-App Message の表示を抑制を解除して、In-App Message の表示処理を呼び出す

です。

1. 表示の抑制方法

FirebaseInAppMessaging.getInstance().setMessagesSuppressed(true) を呼び出せば、In-App Message の表示を抑制することができます。

FirebaseInAppMessaging.java
/**
 * Enable or disable suppression of Firebase In App Messaging messages
 *
 * <p>When enabled, no in app messages will be rendered until either you either disable
 * suppression, or the app restarts, as this state is not preserved over app restarts.
 *
 * <p>By default, messages are not suppressed.
 *
 * @param areMessagesSuppressed Whether messages should be suppressed
 */
@Keep
public void setMessagesSuppressed(@NonNull Boolean areMessagesSuppressed) {
  this.areMessagesSuppressed = areMessagesSuppressed;
}

実装を見てもわかるとおり、単に suppress するか否かのフラグの書き換えだけです。
suppress を false にしたからといって、そのタイミングで In-App Message の表示処理が実行されるわけではありません。
(JavaDoc コメントにもそのあたりの言及はありませんでした…)

2. LaunchActivity#onDestroy の検出

Application.ActivityLifecycleCallbacks を使えば検出が可能です。
後述しますが、In-App Messaging は最前面の Activity が resumed になったら表示処理が実行されるので、

  • LaunchActivity#onDestroy になった
  • MainActivity#onResume になった

の2つの条件を満たしたときに、 MainActivity で何らかの callback を受け取れるようにすれば OK です。

もうちょっとやりようはあるかも知れませんが…

InAppMessagingDelayHelper.kt
object InAppMessagingDelayHelper : Application.ActivityLifecycleCallbacks {

    private val targetActivityName: String = MainActivity::class.simpleName.orEmpty()
    private val backgroundActivityName: String = LaunchActivity::class.simpleName.orEmpty()

    private val destroyed = MutableLiveData<Boolean>().apply { value = false }
    private val resumed = MutableLiveData<Boolean>().apply { value = false }

    init {
        FirebaseInAppMessaging.getInstance().setMessagesSuppressed(true)
    }

    val canShow: LiveData<Boolean> = Transformations.distinctUntilChanged(
        MediatorLiveData<Boolean>().apply {
            value = false
            listOf(destroyed, resumed).forEach { liveData ->
                addSource(liveData) {
                    val isDestroyed = destroyed.value ?: false
                    val isResumed = resumed.value ?: false
                    value = isDestroyed && isResumed
                }
            }
        }
    )

    override fun onActivityDestroyed(activity: Activity?) {
        destroyed.value = (backgroundActivityName == activity?.localClassName)
    }

    override fun onActivityResumed(activity: Activity?) {
        resumed.value = (targetActivityName == activity?.localClassName)
    }

    override fun onActivityPaused(activity: Activity?) = Unit

    override fun onActivityStarted(activity: Activity?) = Unit

    override fun onActivitySaveInstanceState(activity: Activity?, outState: Bundle?) = Unit

    override fun onActivityStopped(activity: Activity?) = Unit

    override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) = Unit

}

を作って、 Application で registerActivityLifecycleCallbacks しておけば

override fun onCreate() {
    super.onCreate()
    registerActivityLifecycleCallbacks(InAppMessagingDelayHelper)
    ...
}

MainActivityLaunchActivity is destroyed && MainActivity is resumed の状態になったコールバックを受け取ることができます。

MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    ....

    InAppMessagingDelayHelper.canShow.observe(this, Observer { canShow ->
        if (canShow) {
            // LaunchActivity is destroyed && MainActivity is resumed
            // TODO : In-App Message の表示処理の呼び出し
        }
    })
    ...
}

3. In-App Message の表示処理を呼び出す

In-App Message の表示処理は、 FirebaseInAppMessagingDisplay#onActivityResumed から呼び出されています。

FirebaseInAppMessagingDisplay.java
@Override
public void onActivityResumed(Activity activity) {
  super.onActivityResumed(activity);
  if (inAppMessage != null) {
    showActiveFiam(activity);
  }
}
...
private void showActiveFiam(@NonNull final Activity activity) {
  if (inAppMessage == null || headlessInAppMessaging.areMessagesSuppressed()) {
    Logging.loge("No active message found to render");
    return;
  }
  // 表示処理
}

showActiveFiam(...) は private ですが、呼び出し元の onActivityResumed(...) は public なので、
FirebaseInAppMessagingDisplay.getInstance() でインスタンスを取得すれば呼び出すことができます。

つまり、

InAppMessagingDelayHelper.canShow.observe(this, Observer { canShow ->
    if (canShow) {
-       // LauncherActivity is destroyed && MainActivity is resumed
-       // TODO : In-App Message の表示処理の呼び出し
+       FirebaseInAppMessaging.getInstance().setMessagesSuppressed(false) // 抑制解除
+       FirebaseInAppMessagingDisplay.getInstance().onActivityResumed(this) // 表示処理の呼び出し
    }
})

のようにすれば、In-App Message がちゃんと表示されるようになります。

アプリケーションによって、Landing する Activity までに、どんな Activity が表示されては消えるのかがまちまちだと思うので、それぞれのアプリケーションに応じた回避方法を採る必要があると考えています。
あくまでも参考程度にして下さい。

本件は Firebase Android SDK の GitHub Issue で報告しているので、あわよくば、将来的にはこの workaround は不要になるかも知れません。
(不要になることを祈ってます。)
https://github.com/firebase/firebase-android-sdk/issues/1324

 

それ以外に調べた事

調査したときにわかったことを、ついでなので記しておきます。

メッセージレイアウトのトップバナーは Push 通知ではない

これは、単に勘違いしていただけですが、一応記載。
スクリーンショット 2020-03-22 1.16.17(2).png

スクリーンショット 2020-03-22 1.16.21(2).png
こんな感じで Push 通知っぽく表示されますが、Push 通知ではありません。
つまり、アプリを開かないと表示されません。

アプリの起動を促進させるためには、Cloud Messaging を使う必要があります。
※In-App Messaging と Cloud Messaging を併用すれば、Push 通知経由でアプリを開いた場合のみに、特定の In-App Message を表示させることも可能です。

In-App Message を表示している状態で別 Activity を表示させたときの挙動

新たに開いた Activity の上に、In-App Message が新たに表示される。
(既に表示している In-App Message が、新たに開いた Activity に隠れる…と思ってましたが、大丈夫でした!)

In-App Message 表示させる方法

1. キャンペーンを作成して、テストデバイスに送る

https://firebase.google.com/docs/in-app-messaging/get-started?authuser=0&platform=android
によると、

電力を節約するため、Firebase アプリ内メッセージングはサーバーからのメッセージの取得を 1 日に 1 回だけ行います。この設定の場合、テストが困難になることがあるため、メッセージをオンデマンドで表示するテストデバイスを Firebase コンソールで指定できます。

と書かれています。
この手ももちろん使えます。
(でも、若干めんどくさい…)

2. コードでダミーのメッセージを生成して、メッセージの表示処理を呼び出す

Firebase Android SDK のソースコード を見てみると、どうやら、コードで In-App Messaging の表示処理を実行することができそうです。

リンク先が変わるかも知れないので、コードを引用しておきます。

com.example.firebase.fiamui.MainActivity.java
ModalMessage message =
    builder
        .setBackgroundHexColor(bodyBackgroundColorString)
        .setTitle(title)
        .setBody(body)
        .setImageData(imageData)
        .setAction(modalAction)
        .build(campaignMetadata, data);

FirebaseInAppMessagingDisplay.getInstance()
    .testMessage(this, message, new NoOpDisplayCallbacks());

これを LaunchActivity#onCreate(...) などで実行します。
この手を使うと、色んなタイミングで In-App Messaging の表示処理の呼び出しを再現することが可能です。
表示処理の呼び出しのタイミングを変えてみたり、表示時や表示中の Activity の状態を変えてみるには、この方法が手軽でオススメです。

具体的に、どんな場合に In-App Messaging の表示処理が呼び出されるのかは後述します。

In-App Message の表示処理の発動

InAppMessageStreamManager.java
public Flowable<TriggeredInAppMessage> createFirebaseInAppMessageStream() {
  return Flowable.merge(
          appForegroundEventFlowable,
          analyticsEventsManager.getAnalyticsEventsFlowable(),
          programmaticTriggerEventFlowable)
      .doOnNext(e -> Logging.logd("Event Triggered: " + e))
      .observeOn(schedulers.io())
      ...色んな処理... // キャンペーン取得の通信が絡めば時間が掛かる可能性がある
      .observeOn(schedulers.mainThread())

これを、 FirebaseInAppMessaging の constructor で subscribe しています。
(内部では RxJava を使ってるんですね!)

FirebaseInAppMessaging.java
public class FirebaseInAppMessaging {
  FirebaseInAppMessaging(...) {
    ...
    Disposable unused =
        inAppMessageStreamManager
            .createFirebaseInAppMessageStream()
            .subscribe(FirebaseInAppMessaging.this::triggerInAppMessage);
  }
  ...
  private void triggerInAppMessage(TriggeredInAppMessage inAppMessage) {
    if (this.fiamDisplay != null) {
      fiamDisplay.displayMessage(
          inAppMessage.getInAppMessage(),
          displayCallbacksFactory.generateDisplayCallback(
              inAppMessage.getInAppMessage(), inAppMessage.getTriggeringEvent()));
    }
  }
  • アプリが最前面に来たとき
  • Analytics の Event が発行されたとき
  • programmaticTriggerEvent が発行されたとき

に In-App Message の表示処理が開始されます。
ただし、バックグラウンド処理が完了してから、UI への表示処理の実行になるので、その間に Activity が遷移・終了している可能性も十分あり得ます。

しかしながら、UI への表示処理を行ったタイミングで最前面の Activity に表示されるようになっていました。
(逆に言えば、 MainActivity#onCreate でイベントを送信し、それをトリガーに In-App Messaging を表示するように設定しても、画面遷移をしてしまえば、別の Activity で表示されることもある)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GoogleMapsAPI備忘録(2020年3月時点)

はじめに

今までの案件で使ったGoogleMapsAPIについての備忘録です。
APIの仕様や制限が変わるので、忘れる前に備忘録を残します。

GoogleMapsAPI

公式URL

マップ

  • Maps SDK for Android
    地図を Android アプリに追加します。

  • Maps SDK for iOS
    地図を iOS アプリに追加します。

  • Maps Static API
    シンプルで埋め込み可能な地図画像を最小限のコードで追加します。

  • Maps JavaScript API
    ウェブサイトにインタラクティブな地図を追加します。独自のコンテンツと画像でマップをカスタマイズできます。

  • Street View API
    360 度のパノラマ画像を使用して実世界の画像を埋め込みます。

  • マップ URL
    Google マップを起動し、クロスプラットフォームの URL スキームを使用して、
    検索やルート検索などの操作を開始します。

ネイティブアプリ開発の案件だったため、Android/iOSそれぞれのSDKを使用しました。
検索すれば、やりたいことを実現するための方法を教えてくれるサイト・記事が多く、
特に詰まった部分はありませんでした。

ルート

  • Directions API
    複数の場所間の公共交通機関、自転車、車、徒歩でのルートを提供します。

  • Distance Matrix API
    複数の目的地について移動時間と距離を計算します。

  • Roads API
    車両が走行する正確なルートを決定します。

Directions APIを使用しました。
ネイティブアプリでもAPIを直接使用する形式になるので、公式ドキュメントでほとんどこと足りました。
一点、そもそもで詰まった部分がGoogle mapのAPI KeyをAndroid/iOSで使用しても、
APIが正常に動きませんでした。
公式ドキュメントに以下の一文があるので、WebのKeyでないとダメなのかもしれません。
(バックエンドチームにラップしたAPIを作ってもらいました..)

This service is also available as part of the client-side Maps JavaScript API, or for server-side use with the Java Client, Python Client, Go Client and Node.js Client for Google Maps Services.

プレイス

  • Places SDK for Android
    多くの場所の豊富な詳細を Android アプリに追加します。ユーザーのクエリにオートコンプリートの結果を提供します。住所と地理座標を変換します。

  • Places SDK for iOS
    多くの場所の豊富な詳細を iOS アプリに追加します。ユーザーのクエリにオートコンプリートの結果を提供します。住所と地理座標を変換します。

  • プレイス ライブラリ、Maps JavaScript API
    多くの場所の豊富な詳細をウェブサイトに追加します。ユーザーのクエリにオートコンプリートの結果を提供します。住所と地理座標を変換します。

  • Places API for Web
    HTTP リクエストを使用して、多くの場所の最新情報を取得できます。

  • Geocoding API
    住所を地理的座標に変換したり、地理的座標から住所を特定したりできます。

  • Geolocation API
    携帯電話の基地局や Wi-Fi ノードからの位置データを利用して、GPS に依存せずにデバイスの位置を返します。

  • Time Zone API
    特定の緯度と経度のタイムゾーンを取得します。

PlacesのSDKを使用しました。
公式ドキュメントに書いてあることで事足りましたが、どう使うかを考える必要はありました。
AutoCompleteを実現するときにFragmentやActivityのようなWidgetごと導入するか、
API実行のように導入するか選択できます。
どちらも試してみましたが、UIデザインが決まっている場合は後者の方が自由が効きます。

まとめ

はじめは、案件で使ったAPIで適当なアプリを作ろうと思ったんですが、
諸々時間が足りずひとまずは記憶のあるうちにメモだけを残しました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む