- 投稿日:2020-12-18T23:18:10+09:00
webViewでandroidのデフォルトのエラーページが表示される/チラつく時の対処方法
注意
本記事はreact nativeの記事です。
はじめに
webViewでページを表示する時に何らかの読み込みエラーとかが出るとあまり印象が良くないので
renderError={() => { return ( <View> <Text>読み込みできませんでした</Text> </View> ) }}みたいな感じでエラー用の表示を作ると思うのですが、なんとAndroidだとrenderErrorを無視してデフォルトのエラーページがでーんと残ったりページを開く時や閉じる時にエラーページがチラつくといった現象が発生します。
色々と調べて対処したので、お困りの方は是非お試しください。対応内容
- ページを表示する時にディレイをかける
- ディレイ中は読み込みインジケーターを表示させる
- エラーになった場合は
about:blank
のページを読み込むstate = { url: '', // webViewで読み込む用のurl loading: true // ディレイ用の変数 } componentDidMount(){ // 読み込むページのurlを格納する this.setState({ url: this.props.url }) // androidの時、1秒のディレイをかける Platform.OS === 'android' ? setTimeout(() => { this.setState({ loading: false }) }, 1000) : this.setState({ loading: false }) } // blankページをセットする関数用意 setUrlBlank() { this.setState({ url: 'about:blank' }, () => {}) } render() { return ( {!this.state.loading ? ( <WebView source={{ uri: this.state.url }} onError={e => { this.setUrlBlank() }} /> ) : ( <View style={styles.loadingIndicatorContainer}> <ActivityIndicator size="large" /> </View> ) }解説
まず、ページを読み込んだ時のチラつきを防ぐために、ページ表示前にディレイをかけます。
ディレイ中にインジケーターを表示させることで、ユーザーのストレスを軽減します
エラーになった時にデフォルトエラーページが出ないようにabout:blank
のページをwebViewで読み込ませます。
ただこれだとエラー時に真っ白なページを表示するだけになってしまうので、別途エラー用のHTMLを用意して
読み込ませてあげるとなお良いと思います。これで一旦は対応ができますが、個人的にはもっとベストプラクティスがありそうな気がしています。。
- 投稿日:2020-12-18T19:13:12+09:00
【ReactNative】実機ビルド、APK生成で起きたトラブルシューティング~Android編~
ReactNativeを頑張っているみなさんこんにちは。ブリューアスのwebフロントエンジニアのsuginokoです。
もう年末ですね。1年というのはあっという間です。弊社ではじわじわとReactNativeの案件にも関わるようになってきておりワタシも初めての経験でアタフタしましたが、なんとか今年のゴールにたどり着けそうです。
今回は初めてのReactNativeということで沢山のトラブルに出くわしたので、その対応トラブルシューティングを書いていこうと思います。アプリ開発に携わったことのない同じようなフロントエンジニアさんに届きますように~
前置き
今回1からプロジェクトを立ち上げたわけではなく、既に実装済みのReactNativeのプロジェクトを頂きました。
実装内容としましても少し古い書き方になっている箇所も多々ありましたので、そちらをリファクタを行ったり、見た目部分を修正するなど、実装自体はそこまで重くなかった対応になります。
辛かったのは最初(環境構築)と最後(実機ビルド、APK生成)です。環境
- react: 16.9.0
- react-native: 0.61.5
- react-native-agora: 2.9.1-alpha.2
- react-native-firebase: 5.6.0
※パッケージはおおまかに使用したものだけ記載
基本的にはwindowsで実装してますが、expoは使用しておらず、ios開発は別途Macを用意して確認しつつの実装を行ってました。
yarnも使ってます。お客様の環境が開発用と本番用としか分かれてなかったため、buildTypeとしては
- Debug(開発環境)
- Staging(開発環境を参照しているデプロイゲート更新用)
- Release(本番)
このように分けました。
今回の納期までのスケジュールではもろもろのパッケージのバージョンアップは行わない方針でいったので後に書きますトラブルに見舞われた可能性があります。。トラブルシューティング
※エラーを無くすだけなので、その設定はちょっと・・・というのがあるかもしれませんがご了承ください。
error E:\my-gridsome-site\node_modules\sharp: Command failed.Command: (node install/libvips && node install/dll-copy && prebuild-install) || (node-gyp rebuild && node install/dll-copy)...
1から実装したものでは無くて、他のプロジェクトを取り込む際に起こるエラーなようです。
nodeのバージョンを適切なバージョンに上げて解決。(多分このエラーの下の方に適切なnodeバージョンが書いてあった気がする)
Macでは
sharp: Command failed · Issue #585 · gridsome/gridsome · GitHub
上記だと解消するらしいです。nodistを入れていたらnpxが使えなくなった話
Windows限定な話な気がしますが
Nodist を入れたら npx が使えなくなったので手動でインストール / Twin Turbo Computing
こちらで解決。Task :app:processDebugGoogleServices FAILED
cd `{project_name}` cd android && ./gradlew cleanたまに、上記の対応で直ることがありますが、
firebaseのgoogle-services.jsonが原因の可能性もあります。
環境にdebugとrelease以外の環境が存在するとこのエラーに巡り合えます。
applicationIdSuffix
を指定しているかにもよりますが、指定していない場合は{projectName}/android/app/google-services.json からコピーして
{projectName}/android/app/src/debug/google-services.json を作成します。
パッケージ名はandroid/app/src/main/AndroidManifest.xmlに書いているpackageNameを見ます。
applicationIdSuffix
を設定していなければmainに記載している名称と同じで問題ありませんが、設定している場合は
{packageName}.{applicationIdSuffix}
になります。これでエラーが無くなります。
applicationIdSuffixを指定している場合はStaging環境にもgoogle-services.jsonが必要だったと思います。{ "project_info": { "project_number": "...", "firebase_url": "...", "project_id": "...", "storage_bucket": "..." }, "client": [ { "client_info": { "mobilesdk_app_id": "...", "android_client_info": { "package_name": "←ここを環境ごとに合わせる" } }, ・・・参照:https://noy.hatenablog.jp/entry/2018/02/15/121431
No matching client found for package name
Task :app:processDebugGoogleServices FAILED
と同様の対応でなくなりました。
google-services.jsonが適切な場所にないのと、正しいpackage nameになってないことが問題でした。What went wrong: Execution failed for task ':app:mergeReleaseResources'.
重複エラーだそうです。今思えば
{project_name}/android/app/src/main/assetsにindex.android.bundle
が存在しなかったことが原因でしたが以下の方法で解決。
参照:reactjs - React Native 0.57.1 Android Duplicate Resources - Stack Overflow
- {project_name}/android/app/build 全部削除
- {project_name}/android/build 全部削除
- 実行 rm -rf $HOME/.gradle/caches/
- {project_name}/android/app/src/main/assetsのindex.android.bundle を削除(この時点で自分のプロジェクトには存在しなかったのでパス)
- 以下を実行
react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/resこの時結構不要なファイルも出たから削除した気がする・・・・
jsonファイルやnode_modulesのもろもろも色々出てきてビルドには不要だったんで削除しました。Task :app:transformClassesAndResourcesWithR8ForRelease FAILED
transformClassesAndResourcesWithR8ForReleaseの実行に失敗している。
調べてみると諸々のパッケージバージョンが合わないことで発生しているケースがあるらしいです。(多分色々古かったパッケージもあった。バージョンアップはリスキーなので断念)
R8の設定をtrueにすることで難読化や最適化を行ってくれるそうですが、ここではfalseにしていきます。{project_name}/android/gradle.properties
追記#Disables R8 for Android Library modules only. android.enableR8.libraries = false #Disables R8 for all modules. android.enableR8 = false参照:https://developer.android.com/studio/releases
Execution failed for task ':app:transformClassesAndResourcesWithProguardForRelease'. java.io.IOException: Please correct the above warnings first.
以下続き
Warning: there were 1649 unresolved references to classes or interfaces. You may need to add missing library jars or update their versions. If your code works fine without the missing classes, you can suppress the warnings with '-dontwarn' options.Staging用APKを生成するときに出たエラーです。
Proguard 関連の処理が原因らしいです。この辺を-dontwarnを使って制御することができるらしいです。
とはいえ、1649件もなんかバージョンアップしてないとか、クラスに欠陥があるとか、不足ライブラリがあるらしいものを一気になんとかできるのか。。。(調べてみると、件数が少ないと1個1個バージョンアップやらすることで解消することもあるそうです)
実機ビルドでは普通にアプリが動いていることから、-dontwarnなどをproguard-rules.proを解消できそうであると考え、以下の対応を行っています。{project_name}/android/app/build.gradleのbuildTypeにstagingで起きたエラーなので、
stagingにproguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
を追加
これでproguard-rulesを見に行きます。(多分ここまでたどり着くのに2日くらい)buildTypes { debug { ・・・ } staging { ・・・ // add proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } release { ・・・ } }次にproguard-rulesの設定。
{project_name}/android/app/proguard-rules.pro
元々の専任の方が書いてあった記述にプラスしてReactNative系の処理とWarningはスルー、もろもろの処理をスルーしますよ的な書き方だったりを追記。# Add project specific ProGuard rules here. # By default, the flags in this file are appended to flags specified # in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt # You can edit the include path and order by changing the proguardFiles # directive in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: -keep class io.agora.**{*;} # THIS IS VERY VERY BAD. REMOVE AS SOON AS VERSIONING IS FIXED -dontwarn ** -dontnote ** -keep class host.exp.exponent.generated.AppConstants { *; } ##### Crashlytics ##### -keepattributes SourceFile,LineNumberTable ##### React Native ##### -keep,allowobfuscation @interface **.facebook.proguard.annotations.KeepGettersAndSetters -keep,allowobfuscation @interface **.facebook.react.bridge.ReadableType -keepclassmembers @**.facebook.proguard.annotations.KeepGettersAndSetters class * { void set*(***); *** get*(); } -keep class * extends **.facebook.react.bridge.JavaScriptModule { *; } -keep class * extends **.facebook.react.bridge.NativeModule { *; } -keepclassmembers class * { @**.facebook.react.uimanager.UIProp <fields>; } -keepclassmembers class * { @**.facebook.react.uimanager.ReactProp <methods>; } -keepclassmembers class * { @**.facebook.react.uimanager.ReactPropGroup <methods>; } ##### Versioned React Native ##### -keep class **.facebook.** { *; } -keep class abi** { *; } -keep class versioned** { *; }本当はもっと書いてあったけど、いらなそうなのがあったんでその辺は削除。
多分、先人がReactNativeのバージョンとか上げてしまったり、その他のパッケージとかもものすごく古いパッケージとかもあったし、色々な苦労が見えた結果かなと思う。これが正しかったのかわからないけど、このエラーはこうすることで消えました。(proguardの設定も意味がわからなくて2日くらいかかって合わせて4日くらいかかってしまった。。)Execution failed for task ':react-native-orientation:verifyReleaseResources
自分でreact-native-orientationを入れた記憶がないので先人のものでしょう。
参考:Execution failed for task ':react-native-orientation:verifyReleaseResources' · Issue #290 · yamill/react-native-orientation · GitHub{project_name}/android/gradle.bundle に以下を追加
buildscript{ ...} allprojects { ...} subprojects { afterEvaluate { project -> if (project.hasProperty("android")) { android { compileSdkVersion 28 // version of compile sdk used for project buildToolsVersion '28.0.3' // version of build tool used for project } } } }これは同じ{project_name}/android/gradle.bundleに記載しているbuildscriptにある、
compileSdkVersion
とbuildToolsVersion
を合わせないといけないです。
ちなみに、こちらのバージョンも28以上でないといけなかったらしいです。こちらのプロジェクトでは偶然28以上を使ってたので、問題ありませんでした。Task :@react-native-community_async-storage:generateDebugBuildConfig FAILED
cd android && ./gradlew clean で解消
キャッシュが残っていることがあるらしいです。Execution failed for task ':app:installDebug'. > com.android.builder.testing.api.DeviceException: com.android.ddmlib.InstallException: INSTALL_FAILED_VERSION_DOWNGRADE
cd android && ./gradlew cleanだけでは直らず、普段実機ビルドできているのにどうして?というエラー
PC再起動で直りました。Task :app:mergeDebugResources FAILED
このエラーの前後にエラーの原因となっているログが書いているはずで、自分の記憶にないエラー内容だと、
cd android
./gradlew clean
で解決することがあるでも大概は「あ、これ自分触ったやつ」っていうエラーもあるので、そちらに問題があることもあったりしました。
Task 〇〇:app:packageDebug FAILED
cd android
./gradlew clean
で解決することがあるが、この場合はAndroidStudioがメモリを食っていて出来ない場合もあったので、AndroidStudioで
1.キャッシュ消しての再起動
2.build>clean projectで解消以上。
開発期間が短かったので出来る限りのことをやろうと思って必死に調べました。
使っているパッケージやライブラリが古く、もうドキュメントすら残されてないのが多い中での対応だったので、そこが一番しんどかったです。でもこれでAndroid設定周りの対応学べた気がしますね
次はiOS編を書いていきます。(Androidほどは無いかもしれない)
- 投稿日:2020-12-18T18:59:21+09:00
Jetpack ComposeのLaunchedEffectについて
Jetpack Compose で開発する上できっと使うことになるであろう
LaunchedEffect
についてみていこうと思います。※この記事は Jetpack Compose 1.0.0-alpha09 時点の内容のため、stable までに大きく変わる可能性があります
LaunchedEffect
とは
- Composable がコンポジションに入る時に実行される関数
- コンポジションを離れる時に実行している処理をキャンセル
- 引数の subject が変更された時にも実行している処理をキャンセルして再度実行
- 実行する処理は
CoroutinesScope
で行われる箇条書きすると上記のことを行う関数で、Composable が表示されるタイミングに何か処理を実行したい時に使用します。
LaunchedEffect
の使い方ただ Composable が表示されるタイミングだけ処理をしたい場合は以下のように subject に Unit を指定します。
@Composable fun SplashScreen( onTimeOut: () -> Unit ) { LaunchedEffect(Unit) { delay(SplashWaitTime) onTimeOut() } ... }subject に値を渡す場合だと、例えば検索時のサジェストの処理に使えたりします。
@Composable fun SearchScreen() { ... var searchQuery by remember { mutableStateOf("") } LaunchedEffect(searchQuery) { // execute search and receive result } ... }
LaunchedEffect
の実装を覗いてみる
LaunchedEffect
のコードをみると subject を remember で保存し、変更があれば処理を再実行するようになっています。@Composable @ComposableContract(restartable = false) fun LaunchedEffect( subject: Any?, block: suspend CoroutineScope.() -> Unit ) { val applyContext = currentComposer.applyCoroutineContext remember(subject) { LaunchedEffectImpl(applyContext, block) } }
LaunchedEffectImpl
の方では Composable のライフサイクルを扱うCompositionLifecycleObserver
でコンポジションに入った時に処理の実行、離れた時でJob
のキャンセルを行っています。internal class LaunchedEffectImpl( parentCoroutineContext: CoroutineContext, private val task: suspend CoroutineScope.() -> Unit ) : CompositionLifecycleObserver { private val scope = CoroutineScope(parentCoroutineContext) private var job: Job? = null override fun onEnter() { job?.cancel("Old job was still running!") job = scope.launch(block = task) } override fun onLeave() { job?.cancel() job = null } }参考
- 投稿日:2020-12-18T17:23:31+09:00
【Android】Image Asset Studio でアプリアイコンを作成する方法
プログラミング勉強日記
2020年12月18日
Android Studioでアイコンを作ることができるのを知ったのでその方法をまとめる。Image Asset Studio でアプリアイコンを作成する
1.
ファイル(File)
→新規(New)
→画像アセット(Image Asset)
を開く2. 好きなようにアイコンを作成する
アイコンタイプ(Icon Type)は
Launcher Icons (Adaptive and Legacy)
を選択する。テキストの色とフォントを変更するオプションがあるので自由に変更する。3. アイコンを作成したら
次へ(Next)
→完了(Finish)
を押す
次へ(Next)
を押すとファイル作成のプレビューが見れる。完了(Finish)
でファイルが作成される。注意
Adaptive Icon:foregroundとbackground画像をアプリが提供すれば、システムがこの2つの画像を組み合わせて使用する概念
Legacy Icon:Adaptive Iconが出る前のアイコンAdaptiveアイコンはAndroidのO(API 26)以上で動作し
mipmap-anydpi-v26/ic_launcher.xml
でVectorに実装されているため、解像度ごとに画像ファイルを作成する必要がない。そのため、AndroidのO(API 26)未満のバージョンではLegacyアイコンを提供する必要がある。参考文献
Image Asset Studio を使用してアプリアイコンを作成する
アンドロイドスタジオでは、Adaptive、一般のアイコンを作成
- 投稿日:2020-12-18T14:40:39+09:00
Let's はじめてのRetrofit for Android in Kotlin(サンプルコード付き)
はじめに
これまでOkHttpでサーバとのHTTP通信を行っていたのですが、最近はRetrofitが流行っているじゃないですか。
この記事ではそんなRetrofitの使い方を説明します。動作環境
この記事の動作環境は以下のとおりです。
Android Studio:4.1
Kotln:1.4.10
Open JDK:1.8
compileSdkVersion:30
targetSdkVersion:30
minSdkVersion:16目標
以下を目標とします。
- RetrofitでHTTP通信
- 文字列としてのJSONを取得する
- JSON取得時にGsonでコンバートする
- Retrofitの非同期処理の実装方法
事前準備
本サンプルで使うサーバはNodeのJSON Serverを利用しています。
サンプルプロジェクトを動作させるには、事前に用意しておいてください。アプリの概要
Retrofitを利用して、JSON ServerにGET、POST、PUT、DELETEでHTTP通信するアプリです。
基本的なRESTサーバにアクセスする機能を持ちます。Retrofitのイメージ
そもそもRetrofitってどうやって動いているのかを理解しなければ、ソースコードを確認しても理解が難しいと思います。
登場人物としては以下です。
- Service(インターフェイス)
- Retrofit
- Service(実装クラス)
- Callクラス
大きな処理の流れとしては以下です。
- はじめに、Service(インターフェイス)を定義しておきます。
どのようなパスにどのようなHTTPメソッドでアクセスするかを定義します。- RetrofitクラスをBuilderクラスを通してインスタンスを取得します。
インスタンス取得の際にベースとなるURL(http://xxx.xxx.xxx/)を設定します。- RetrofitクラスからService(インターフェイス)の実装クラスを生成します。
- Service(実装クラス)からCallクラスを取得します。
- CallクラスでHTTP通信を実施します。
JSON文字列でWebAPIにHTTP通信する実装方法
はじめに、文字列として用意したJSONのデータを送信し、受け取るJSONも文字列として取得する実装方法を説明します。
実行イメージ
サンプルコード
GitHubからダウンロードしてください。
GETメソッドでHTTP通信する実装方法
コード解説
build.gradle(app)への追記
はじめに、Retrofitのライブラリーをdependenciesに追記します。
build.gradle(app)dependencies { implementation 'com.squareup.retrofit2:retrofit:2.9.0' }Serviceクラスの定義
サーバに接続するURLのパスやHTTPメソッド、送信するパラメータなどを抽象メソッドとして定義します。
MyService.ktpackage jp.co.casareal.retrofitbasic import okhttp3.RequestBody import okhttp3.ResponseBody import retrofit2.Call import retrofit2.http.* interface MyService{ @GET("posts") fun getRawResponseForPosts(): Call<ResponseBody> @POST("posts") fun postRawRequestForPosts(@Body body:RequestBody):Call<ResponseBody> @PUT("posts/{id}") fun putRawRequestForPosts(@Path("id") id:String, @Body body:RequestBody):Call<ResponseBody> @DELETE("posts/{id}") fun deletePathParam(@Path("id") id:String ):Call<ResponseBody> }メソッドや引数などにアノテーションを付与することにより様々な設定ができます。
メソッドに付与できるアノテーション
アノテーション 引数 説明 @GET value(String) 引数にパスの設定や送信するパスパラムのパラメータのプレースホルダを{}で記述できます。
引数に@Urlが存在する場合は引数はオプション扱いになります。@POST value(String) 引数にパスの設定や送信するパスパラムのパラメータのプレースホルダを{}で記述できます。
引数に@Urlが存在する場合は引数はオプション扱いになります。@PUT value(String) 引数にパスの設定や送信するパスパラムのパラメータのプレースホルダを{}で記述できます。
引数に@Urlが存在する場合は引数はオプション扱いになります。@DELETE value(String) 引数にパスの設定や送信するパスパラムのパラメータのプレースホルダを{}で記述できます。
引数に@Urlが存在する場合は引数はオプション扱いになります。@Multipart なし マルチパートで送信する場合に付与します。
@POSTなどと併せてせっていします。@Header value(String Array) HTTP Headerを設定できます。単一の場合はString型にします。複数ある場合は、文字列配列で付与します。 メソッドの引数に設定できるアノテーション
アノテーション 引数 説明 @Path value(String)
encoded(boolean){}で設定したプレースホルダに値を設定します。文字列には{}で指定した文字列を指定します。booleanには設定する値がエンコード済みかどうかを指定します。(デフォルトfalse) @Query value(String)
encoded(boolean)クエリパラメータのキーをStringで指定します。booleanには設定する値がエンコード済みかどうかを指定します。(デフォルトfalse) @QueryMap Map
encoded(boolean)クエリパラメータが複数存在する時に利用できます。クエリーパラメータをキーと値のMapで渡します。booleanには設定する値がエンコード済みかどうかを指定します。(デフォルトfalse) @QueryName value(String) クエリーパラメータでキーのみを設定できます。 @Field value(String) HTTPリクエストメッセージボディに付与する値のキーをStringとして指定します。 @FieldMap Map
encoded(boolean)HTTPリクエストメッセージボディに付与する値が複数存在する場合に利用できます。booleanには設定する値がエンコード済みかどうかを指定します。(デフォルトfalse) @Part value(String)
encoding(String)マルチパートで送信するデータのキーをStringで指定し、エンコードの方式もStringで指定します。(デフォルトbinary) @PartMap Map
encoding(String)マルチパートで送信するデータのキーをStringと値をMapで指定し、エンコードの方式もStringで指定します。(デフォルトbinary) @Header value(String) HTTPヘッダーを設定できます。引数には設定するHeaderを指定します。 @HeaderMap Map HTTPヘッダーを複数設定する時に使用します。キーには設定するHeader、値には設定するヘッダーの値を指定します。 JSONを文字列として取得するには、 RequestBody や ResponseBody はOKHttpで提供されているクラスを利用します。詳細はこちらの記事を参照してください。
戻り値はCall型にして、実際にサーバから受け取るデータの型をジェネリクスで指定します。
Retrofitクラスのインスタンス化
Serviceの準備ができたらRetrofitクラスのインスタンスを取得します。
MainActivity.kt// Retrofit本体 private val retrofit = Retrofit.Builder().apply { baseUrl("http://10.0.2.2:3000/") }.build()Retrofit.Builder クラスでRetrofitクラスのインスタンスを生成します。
baseUrl プロパティは必須となっています。baseUrlプロパティは okhttp3.HttpUrl でも大丈夫です。最後にbuildメソッドを呼び出し、インスタンスを取得します。
Service(実装クラス)の取得
RetrofitクラスでService(実装クラス)を取得していきます。
MainActivity.kt// サービスクラスの実装オブジェクト取得 private val service = retrofit.create(MyService::class.java)Service(実装クラス)取得には、Javaのクラス情報を渡します。
Callクラスの取得とHTTP通信
Service(実装クラス)から、実際にHTTP通信を行うCallクラスを取得します。
Service(インターフェイス)に定義したメソッドから行いたいHTTP通信にあったCallクラスを取得します。MainActivity.ktval get = service.getRawResponseForPosts()HTTP通信を行うには、下記の2つのメソッドが用意されています。
メソッド名 説明 execute() 同期処理であるため、ワーカスレッドで呼び出す必要がある。 enqueue(Callback callback) HTTP通信が終わったあとに実行したい処理をCallbackクラスで渡す必要あります。コールバック内はMainスレッドで実行されます。 今回は同期処理のため、取得したCallクラスのexecuteメソッドを呼び出します。
MainActivity.ktscope.launch { val responseBody = get.execute() }レスポンスから文字列の取得
実行結果はokhttp3.RequestBodyで受け取るため、bodyメソッドでレスポンスの内容を取得します。
サイトにstringメソッドでJSONの文字列を取得します。MainActivity.ktresponseBody.body()?.let { myViewModel.result.postValue(it.string()) }POSTメソッドやPUTメソッドでJSON文字列を送信する実装方法
ここからのコードの解説は、GETメソッドとの差分のみを説明します。
コード解説
送信するデータを準備
今回はJSON文字列を生成するユーティリティクラスを作成しました。
Util.ktpackage jp.co.casareal.retrofitbasic.util object Util { fun createJson(id:String = "",title:String, author:String):String ="{" + " \"id\": \"${id}\"," + " \"title\": \"${title}\"," + " \"author\": \"${author}\"" + "}" }実際に送信する文字列を生成します。
MainActivity.ktval json = Util.createJson( title = myViewModel.title.value!!, author = myViewModel.author.value!! )RequestBodyのインスタンスを生成する
送信するデータはRequestBody送信するのでオブジェクトを生成します。
RequestBodyについては、こちらの記事を参照してください。
生成したRequestBodyをService(実装クラス)のメソッドの引数に渡し、送信データをCallクラスに格納します。MainActivity.ktval requestBody = RequestBody.create(mediaType, json) val post = service.postRawRequestForPosts(requestBody)JSON文字列をオブジェクトに自動変換してWebAPIにHTTP通信する実装方法
先までのHTTP通信では送信するデータも文字列で用意し、レスポンスも文字列で受け取るため、データクラスへの変換は実装する必要があります。
しかし、Retrofitにはデータクラスへの変換機能が存在します。
ここからはデータクラスへの自動変換機能の実装方法を説明します。今回は自動変換させるために、Gsonのconverterを利用します。
Gsonについては、この記事を参照してください。また、これまでの説明と重複するところは省略します。
実行イメージ
[実行イメージ.gif]()
サンプルコード
GitHubからダウンロードしてください。
GETメソッドでHTTP通信する実装方法
さっそくGETメソッドのHTTP通信の実装方法を説明します。
コード解説
build.gradle(app)への追記
はじめに、Retrofitのライブラリーをdependenciesに自動変換の機能であるconverterを追記します。
build.gradle(app)dependencies { implementation 'com.squareup.retrofit2:converter-gson:2.9.0' }サーバとやり取りするデータクラスの定義
サーバと送受信するデータクラスを定義しておきます。
Post.ktpackage jp.co.casareal.retrofitwithconverter.model data class Post( val id: String = "", val title: String, val author: String )Serviceクラスの定義
Serviceクラスの定義もCallのジェネリクスや引数をデータクラスの方に変更します。
MyService.ktpackage jp.co.casareal.retrofitwithconverter import jp.co.casareal.retrofitwithconverter.model.Post import okhttp3.RequestBody import okhttp3.ResponseBody import retrofit2.Call import retrofit2.http.* interface MyService { @GET("posts") fun getRawResponseForPosts(): Call<List<Post>> @POST("posts") fun postRawRequestForPosts(@Body post: Post): Call<Post> @PUT("posts/{id}") fun putRawRequestForPosts(@Path("id") id: String, @Body post: Post): Call<Post> @DELETE("posts/{id}") fun deletePathParam(@Path("id") id: String): Call<Post> }Retrofitクラスの取得
自動変換機能を追加するにはRetrofitクラスを取得するときに、自動変換してくれるconverterをしていします。
MainActivity.kt// Retrofit本体 private val retrofit = Retrofit.Builder().apply { baseUrl("http://10.0.2.2:3000/") addConverterFactory(GsonConverterFactory.create()) }.build()addConverterFactoryメソッドで自動変換するconverterを渡します。
今回は、GsonConverterFactory#createでConverterFactoryを取得して渡しています。HTTP通信で取得できる型
converterを指定することにより、JSON文字列→データクラスへ自動で変換されます。
そのため、Call#executeメソッドの戻り値でbodyメソッドを呼び出すと、Service(インターフェイス)で指定したデータクラスの型に変換されます。
MainActivity.ktval list = response.body()POSTメソッドやPUTメソッドでJSON文字列を送信する実装方法
ここからのコードの解説は、GETメソッドとの差分のみを説明します。
コード解説
基本的に差分としては、引数に送信するデータをデータクラス型にしておいても、送信時にJSONへ自動変換されます。
送信するデータを準備
データクラスは定義済みであるため、データクラスのインスタンスに送信するデータを設定します。
MainActivity.ktval data = Post(title = myViewModel.title.value!!, author = myViewModel.author.value!!)Service(実装クラス)のメソッドにデータクラスのオブジェクトを渡す
JSONに自動変換するConverterを設定しているので、データクラスをJSON文字列に自動変換してくれます。
そのため、ここではデータクラスのオブジェクトを渡します。MainActivity.ktval post = service.postRawRequestForPosts(data)その他はこれまで説明した通りの内容です。
HTTPを非同期通信で実行する
これまで通信処理は同期処理をおこなうメソッドで実行していました。しかし、昨今のAndroidでは通信を非同期処理で行います。
もちろんRetrofitも非同期処理に対応したメソッドが用意されています。
移行は非同期処理の実装方法について説明します。また、これまでの説明と重複するところは省略します。
実行イメージ
サンプルコード
GitHubからダウンロードしてください。
非同期処理でHTTP通信を行う実装方法
HTTPメソッドに関わらず、非同期処理の実装方法は共通です。
コード解説
非同期で通信を行う
非同期処理を行うには先に説明したとおり、Call#enqueueを呼び出します。
引数にはCallbackクラスのオブジェクトを渡します。
CallBackクラスは戻り値の型をジェネリクスで指定します。MainActivity.ktval get = service.getRawResponseForPosts().also { it.enqueue(object : Callback<List<Post>> { override fun onFailure(call: Call<List<Post>>, t: Throwable) { Toast.makeText(this@MainActivity, "通信エラー", Toast.LENGTH_SHORT).show() Log.e("TEST", "通信エラー", t) } override fun onResponse(call: Call<List<Post>>, response: Response<List<Post>>) { myViewModel.result.value = response.body().toString() } }) } }CallBackクラスには2つのメソッドがあり。
HTTP通信の成否によって実装内容を記述できます。
メソッド名 引数 説明 onFailure Call call
Throwable tCallは実行したHTTP通信のCallのオブジェクト。Throwableは通信に失敗したときのエラーの内容が格納されています。 onResponse Call call
Response responseCallは実行したHTTP通信のCallのオブジェクト。Responseはexecuteのときと同じResponseオブジェクトです。 まとめ
Retrofitを使ってきましたが、ある程度OKHttpなどを理解していないと難しいところもありますが、非同期処理に対応したメソッドがあり便利だと感じました。
- 投稿日:2020-12-18T12:23:14+09:00
モバイルチームの成長とKMM導入に向けて
はじめに
株式会社RevCommでモバイルアプリを担当している木下です。
この記事は 2020年のRevCommアドベントカレンダー 19日目の記事になります。18日目は @zomaphone さんの 【既存の解析システムに対して pytest-mock と pydantic を活用してクイックに総合テストを実装した話】 でした。MiiTel Phone Mobile アプリについて
開発している MiiTel Phone Mobile アプリの主な機能はVoIPで、RevComm のメインプロダクトである MiiTel のオプションとしてご利用いただけます。当初は Cordova で開発されたβ版アプリが無料で提供されていましたが、アプリを起動していないと着信が受けられないなどの不都合があり、これらのユーザー体験を改善するためにネイティブ(Swift/Kotlin)で開発し直したものが現行のアプリとなります。
モバイルチームについて
モバイルアプリの開発を担うモバルチームの変遷を簡単に紹介します。
1. 一人チーム時代
約2年前にRevCommにジョインしてから、iOSとAndroidの両アプリを開発・リリースし、サポート対応や機能改善、OSアップデート対応などをほぼ一人でやってきました。アプリの実装だけでなく、モバイルアプリのための 通信サーバー の設定やサーバーアプリの改修なども対応してきました。
2. 新メンバー参入
今年の10月ついにモバイルチームに、新しいメンバーが2名加わりました。ともにiOSアプリとAndroidアプリの開発経験があり、どちらも安心して任せられるメンバーです。
私としては フルリモート・フルフレックスタイム制を導入しているRevComm で社員を迎え入れるのが初めてでした。組織として急成長してきたことや、リモートワークを推奨していることにより、コミュニケーションの不足や取りづらさの課題を以前から感じていたため、この点を特に留意し、新しいメンバーが発言・活動しやすい環境にしてプロダクトの成長に貢献できるようなチーム作りを目指しました。具体的な内容については、また機会があれば紹介したいと思います。3. そしてさらなる成長に向けて
新たなメンバーが加わってチームとして機能するようになり、開発のスピードも上がってきました。これまでのワンオペでは機能を追加するとしても、iOSアプリの実装とリリースをしてから、Androidも同様の対応をするというフローとなり、リリースのタイミングがズレることがほとんどでしたが、メンバーが増えたことで両アプリ同時に新機能をリリースするといったことも可能になってきました。
そして、これからさらにスピードを上げるために導入を検討しているのが、マルチプラットフォーム開発です。
一つの機能を追加するのに、SwiftとKotlinで同様のロジックを実装しなくてはいけない、というのは非効率であると感じており、これを解消するためにもマルチプラットフォーム開発を導入したいと考えています。
React Native、Flutter、KMM(Kotlin Multiplatform Mobile) などありますが、既存のコードを生かしつつ、徐々に共通化が進められそう、かつ言語的に従来のモバイルエンジニアでもスムーズに開発できそうな、KMM が有力であると考えています。私自身 前職 ではAndroidアプリをメインでやっていたこともあり、KotlinでiOSアプリも開発できたら嬉しいなと考えていたところで、 Kotlin Multiplatform Mobile がアルファ段階に移行 というニュースを目にしたのをきっかけに、導入意欲が高まりました。KMM(Kotlin Multiplatform Mobile) 入門
以前から Kotlin/Native という形でマルチプラットフォーム向けの開発環境は提供されていましたが、KMM ではiOSとAndroidのモバイルアプリに特化し Android Studio などの IDEに統合可能な環境となっており、より簡単に扱えるようになったと認識しています。
個人的にFlutterは少し触っている(Widgetの充実具合や高速な Hot reload は素晴らしいですよね)のですが、KMM は未経験でしたのでこの機会(Advent Calendar)に少し触ってみようと思い、KMM Shared Module
を作成して、AndroidアプリとiOSアプリのそれぞれでインポートして動作させる、ことをやってみようと思います。1. 開発環境
公式サイト にも記載されていますが、次のような環境が必要となります。
- Android Studio 4.1 以降
- Xcode 11.3 以降
- (AS) Kotlin plugin 1.4.20 以降
- (AS) Kotlin Multiplatform Mobile plugin
- JDK
2. Androidプロジェクト作成
KotlinNativeAndroid
というプロジェクトを作成しました。
3. KMM Shared Module 作成
KotlinNativeAndroid
を開いた状態でメニューのFile
>New
>New Module...
を選択しKMM Shared Module
を選択してからNext
をクリックします。
次の画面で
Generate packFoxXcode Gradle task
をチェックしてからFinish
をクリックします。(他はデフォルトのままとしました)
以下のような内容となっており、OSのバージョンを含む文字列を返すメソッドがサンプルとして作成されていることが分かります。
commonMain/.../Greeting.ktpackage com.example.kmmsharedmodule class Greeting { fun greeting(): String { return "Hello, ${Platform().platform}!" } }commonMain/.../Platform.ktpackage com.example.kmmsharedmodule expect class Platform() { val platform: String }androidMain/.../Platform.ktpackage com.example.kmmsharedmodule actual class Platform actual constructor() { actual val platform: String = "Android ${android.os.Build.VERSION.SDK_INT}" }iosMain/.../Platform.ktpackage com.example.kmmsharedmodule import platform.UIKit.UIDevice actual class Platform actual constructor() { actual val platform: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion }
KotlinNativeAndroid
プロジェクト内のファイルも自動で変更されます。settings.gradleinclude ':kmmsharedmodule' <- 追加gradle.propertieskotlin.mpp.enableGranularSourceSetsMetadata=true <- 追加 kotlin.native.enableDependencyPropagation=false <- 追加4. Androidプロジェクト修正
app/build.gradledependencies { implementation project(':kmmsharedmodule') <- 追加 }
KMM Shared Module
から文字列を取得して、画面に表示する処理を実装します。MainActivity.ktclass MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityMainBinding.inflate(layoutInflater) binding.textView.text = Greeting().greeting() setContentView(binding.root) } }ここで実行してみると次のようなエラーが発生しました。
Manifest merger failed : uses-sdk:minSdkVersion 23 cannot be smaller than version 24 declared in library [:kmmsharedmodule] /Users/tkinoshita/kotlin-native-example/KotlinNativeAndroid/kmmsharedmodule/build/intermediates/library_manifest/debug/AndroidManifest.xml as the library might be using APIs not available in 23 Suggestion: use a compatible library with a minSdk of at most 23, or increase this project's minSdk version to at least 24, or use tools:overrideLibrary="com.example.kmmsharedmodule" to force usage (may lead to runtime failures)Moduleの
minSdkVersion
がアプリよりも高いことが原因のようですので、Module のminSdkVersion
をデフォルト値の24
から、アプリの設定と同じ23
に変更します。また compileSdkVersion と targetSdkVersion もアプリに合わせました。build.gradle.ktsandroid { compileSdkVersion(30) defaultConfig { minSdkVersion(23) targetSdkVersion(30) }5. Androidアプリ実行結果
正常に実行されると次のような表示となります。
6. Framework 出力
続いてiOSアプリでもModuleを利用していきますが、iOSアプリ(Xcode)で利用するためには、Framework 形式でライブラリを出力する必要があります。
build.gradle.kts
の下の方に Framework を出力するためのタスクが記述されているので、これを利用します。
Terminal で次のコマンドを実行します。./gradlew :kmmsharedmodule:build成功すると、
kmmsharedmodule/build/xcode-frameworks
内にkmmsharedmodule.framework
フォルダが作成され Framework が出力されます。7. Xcodeプロジェクト作成
次に iOSアプリのプロジェクトを作成していきます。
XcodeでKotlinNativeiOS
という名前のApp
プロジェクトを新規作成します。
8. Xcodeプロジェクト修正
Build Settings
のFramework Search Paths
に Framework のパスを追加します。ここでは$(SRCROOT)/../KotlinNativeAndroid/kmmsharedmodule/build/xcode-frameworks
を設定しています。
アプリに Framework を組み込むための設定を追加します。
KMM Shared Module
から文字列を取得して、画面に表示する処理を実装します。ViewController.swiftimport UIKit import kmmsharedmodule class ViewController: UIViewController { @IBOutlet weak var textLabel: UILabel! override func viewDidLoad() { super.viewDidLoad() textLabel.text = Greeting().greeting() } }9. iOSアプリ実行結果
正常に実行されると次のような表示となります。
10. ソースコード一式
こちらのリポジトリ で公開しています。
おわりに
KMM Shared Moduleの作成から AndroidアプリとiOSアプリで利用する流れが把握でき、おおよその感触がつかめました。どこまでロジックが共通化できるかはまだ未知ですが、期待していたとおり簡単にモジュールの作成と利用ができましたので、これから積極的に採用していきたいと思います。
明日はモバイルバックエンド担当の @rhoboro さんです!!
実は @rhoboro さんもモバイルチームの一員としてバックエンドの開発を担当されています!参考
- 投稿日:2020-12-18T12:06:56+09:00
AWS SNSからAndroidにプッシュ通知するためにやったこと、ハマったこと
今関わっているプロジェクトのバックエンドはAWSです
そんなAWSからユーザが操作するAndroidにプッシュ通知がしたいと思いました
しかしFirebase(FCM)が便利すぎて逆にハマってしまったのでメモしておきますプッシュ通知なんてどうせみんなオフするからいらん!という漢気も私は好きです
参考にした素晴らしい記事様はこちら
Android から Amazon SNS を使ってみる - Qiita
Android端末で、FCM経由でAWSSNSを受け取るまで - Qiita目指すもの
まずは最小限、プッシュ通知をすることだけを目標にします
細かい設定や他の人の方が詳しい!用意するもの
- 適当なAndroidプロジェクト
- Firebaseアカウント ※無料プランでOK
- AWSアカウント
FirebaseからAndroidに通知するまで
適当にAndroidプロジェクトを作成する
Firebaseにアカウントを作成、適当なプロジェクトを作る
「プロジェクトを追加」から適当な名前でプロジェクトを作成します
ちなみにFCM(Firebase Cloud Messaging)はだけなら無料プランでもよさそうです。素敵。Firebase Cloud MessagingにAndroidアプリを登録する
公式がとても丁寧に案内してくれるのでそれに従います
Androidパッケージを登録する
Androidパッケージ名に、先ほど作成したAndroidプロジェクトのパッケージ名を入力します
署名はよりセキュリティを高めるなら入れた方がいいんでしょうね。今回は省略!appディレクトリにJSONファイルを追加
firebaseからダウンロードしたgoogle-services.jsonをapp以下に追加しますAndroidプロジェクトに依存関係を追加する
/build.gradlebuildscript { ext.kotlin_version = "1.4.21" repositories { google() jcenter() } dependencies { classpath "com.android.tools.build:gradle:4.1.1" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "com.google.gms:google-services:4.3.4" // <<<<< added ~/app/build.gradleplugins { id 'com.android.application' id 'kotlin-android' id 'com.google.gms.google-services' // <<< added } // ~略~ dependencies { // ~略~ implementation platform('com.google.firebase:firebase-bom:26.1.1') // <<< added implementation 'com.google.firebase:firebase-analytics-ktx' // <<< added implementation 'com.google.firebase:firebase-messaging-ktx' // <<< added }FCMにAndroidデバイスを登録する
通知用のデバイストークンを取得する
プッシュ通知するためには、Androidデバイスを一意に特定するためのトークンが必要になります
トークンの発行はAndroidプロジェクトに組み込まれたFirebaseがやってくれます
例えば下記のようにtokenをログ出力するようにしてみますMainActivity.ktclass MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // ↓added FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { if (!it.isSuccessful) { Log.e("Firebase", "Fetching FCM registration token failed", it.exception) return@OnCompleteListener } val token = it.result Log.d("Firebase Token", token.toString()) }) } }この状態でアプリを動かすと、ログにトークンが吐かれますので、これをメモしておきます
D/Firebase Token: c3ilo ~~~FCMからプッシュ通知を送信してみる
FCMのダッシュボードから「通知の作成」へ行き、早速メッセージを送ってみます
FCM登録トークンに、先ほどAndroidから生成されたトークンを登録して、テストメッセージを送信します
すると無事届きました!簡単ですねー
さあFirebase > Android間の連携ができたので、次はAWS SNSとの連携だ注意:アプリがバックグラウンドでないと通知が届かない
デフォルトの状態だと、アプリがフォアグラウンド中(前面表示)には届かないようです
「君もうアプリ開いてるから通知せんでええやろ?」ってことでしょうかAWS SNSからAndroidに通知するまで
AWS SNSとFirebaseを紐付ける
AWS公式が非常に親切に手順を説明してくれていますので、基本はこの通りに
Firebaseのサーバーキーを取得する
プロジェクトの設定→「Cloud Messaging」からサーバーキーを取得しますFirebaseサーバーキーをAWS SNSに登録する
- AWSコンソールからSimple Notification Serviceを開く
- 左バーの「プッシュ通知」からモバイルプッシュ通知画面を開き、「プラットフォームアプリケーションの作成」
- プッシュ通知プラットフォームは「FCM」
- APIキーに先ほど取得したFirebaseサーバーキーを入力する
- そのほかは空欄でOK
アプリケーションエンドポイントを作成する
- デバイストークンにAndroidから取得したトークンを入力する
- そのほかは空欄
いざSNSメッセージ送信!しかし全く反応なし
なにせFirebaseからAndroidへのプッシュ通知は成功しているので、AWS SNSとFirebase間の紐付けがうまくいってない?
と思ってサーバーキー周りをめちゃくちゃ確認しましたが、原因は別のところにありました
それでは、トラブルシュート編に続きますAWS SNSからAndroidに通知するために追加でやるべきこと
送信するメッセージにはフォーマットがある
AWS SNSをちょっと知っている人には当たり前だと思いますが...
Firebaseで受け取れるようなメッセージを送るにはフォーマットがあります
しかもAWS側がテンプレートを用意してくれています(カスタムペイロード)しかし、これでもAndroidにはプッシュ通知されませんでした
Android側でレシーバを実装する必要がある
Firebase公式にはしっかりとこんな説明があります
フォアグラウンド アプリで通知メッセージまたはデータ メッセージを受信する場合は、onMessageReceived コールバックを処理するコードを記述する必要があります。
FirebaseからAndroidへの通知は成功していたので、動くと思っていましたが甘かったようです
Androidマニフェストに通知受信用のサービスを追加
マニフェストファイルのタグ以下にサービスを追加します
AndroidManifest.xml<service android:name=".MyFirebaseMessagingService" android:exported="false"> <intent-filter> <action android:name="com.google.firebase.MESSAGING_EVENT" /> </intent-filter> </service>MyFirebaseMessagingService.ktclass MyFirebaseMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) Log.d("onMessageReceived", "From: ${message.from}") if (message.data.isNotEmpty()) { Log.d("onMessageReceived", "payload: ${message.data}") } } }しかしこれでもプッシュ通知が飛んでこないなーと、何気なくログを見てたら
来てたァ!!カスタム通知を作成する
公式のFCMメッセージについてを参照する限り、AWS SNSからのメッセージはデータメッセージとして扱われていて、
その場合はクライアント側で処理をする必要があるとのこと
(SNSから送信するメッセージにdataって付けてますしね)MyFirebaseMessagingService.ktclass MyFirebaseMessagingService : FirebaseMessagingService() { val CHANNEL_ID = "MyNotification" override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) Log.d("onMessageReceived", "From: ${message.from}") if (message.data.isNotEmpty()) { Log.d("onMessageReceived", "payload: ${message.data}") } createNotificationChannel() val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("通知がきたで") .setContentText("内容は「${message.data}」やで") .setSmallIcon(R.drawable.ic_launcher_background) .setPriority(NotificationCompat.PRIORITY_DEFAULT) with(NotificationManagerCompat.from(this)) { notify(UUID.randomUUID().hashCode(), notificationBuilder.build()) } } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val name = "MyNotificationChannelName" val descriptionText = "MyNotificationChannelDescription" val importance = NotificationManager.IMPORTANCE_DEFAULT val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { description = descriptionText } // Register the channel with the system val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } } }正直ここの実装はかなりみようみまねで適当なので、ご使用の際は十分に注意してください
ちなみにこのとき、アイコンなど一部の設定を省略するとランタイムでクラッシュしたりします無事プッシュ通知ができました!
![]()
![]()
ということで無事通知が動きました!
まだ通知周りは詰められてないところもありますが、なんとかなりましたね
- 投稿日:2020-12-18T10:21:53+09:00
Android TVアプリ開発入門 〜あったかいおふとんから出ない生活を夢見て〜
この記事は、株式会社エイチームフィナジー の Advent Calendar 2020 20日目の記事です。
本日は、ナビナビ証券の開発に携わっている@kazenomachiが担当します。
はじめに
2020年12月。
季節は冬、人々の不安を煽るCOVID-19、むやみに外出もできない世の中…
あったかいおふとん
から出ない生活を夢見た私は、ブラックフライデーセールでXGIMI Haloというプロジェクターを買いました
XGIMI HaloにはAndroid TVが搭載されており、何も接続しなくても様々なアプリで動画や音楽を再生でき、とても便利です。
動画を映すだけでも十分に便利で楽しいのですが、合間に天気や予定を確認するのに、毎回おふとん
から腕を出してスマホを手に取るのは煩わしいなと感じました。
そこで、時計などのダッシュボードを表示するAndroid TVアプリを作りたいと思ったので自分で開発をしてみました。
Androidアプリを作ったことのない私が、どのように開発を進めたかを書き記したいと思います。
今回作ったもの
時計、現在の天気、今日の予定一覧を表示するダッシュボードを作りました。
一枚の画面に情報を表示するだけのとてもシンプルなアプリです。
実際にスクリーンに映すとこんな感じです!
(およそ90インチの大画面に映っています)
今回書いたソースコードはGitHubに公開しています。
https://github.com/kazenomachi/Time開発準備
以下の環境で開発を行います。
Android Studioのインストール方法は別の記事に書きました。
ChromebookにAndroid Studioをインストールして日本語化する開発環境
- Pixelbook Go (Core i5)
- Chrome OS (86.0.4240.199)
- Linuxインストール済み (Debian GNU/Linux 10 (buster))
- Android Studio (4.1.1 for Chrome OS)
プロジェクトを作成し、画面を表示する
Android Studioを開いて、プロジェクトを作成します。
プロジェクトのテンプレートで、「Android TV」タブの「No Activity」を選択します。
(Blank Activityも選択してみましたが、最初から多くの画面がありリッチな感じで私には難しくて諦めました)
(日本語化したら文字化けしてしまいましたが)プロジェクト名や保存先などを指定します。
デフォルトで言語は「Kotlin」SDKは「API 21: Android 5.0 (Lollipop)」が選択されていたので、そのまま「完了」を押下してみました。
(余談ですが、私のSpotifyのMy Top Songs 2020で宇多田ヒカルの「Time」が3位だったのでTimeにしてみました。いい曲です。)ここまでできたら、新しいプロジェクトが開きます。
まずは、メイン画面を作成します。
左側のペインで右クリックをして、新規 > アクティビティー > 空のアクティビティー を選択します。
色々ファイルができるので、以下のように編集しました。
(事前にpackage名をcom.example〜
からio.github.kazenomachi.time
に変更しています。)app/src/main/AndroidManifest.xml<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="io.github.kazenomachi.time"> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.Time"> <activity android:name=".TimeActivity"> <!-- intent-filterを追加 --> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>app/src/main/java/io/github/kazenomachi/time/TimeActivity.ktpackage io.github.kazenomachi.time import android.app.Activity import android.os.Bundle class TimeActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_time) } }app/src/main/res/layout/activity_time.xml<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".TimeActivity"> <!-- 文字を表示してみる --> <TextView android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="なんか文字を表示するよ" tools:layout_editor_absoluteX="310dp" tools:layout_editor_absoluteY="164dp" /> </androidx.constraintlayout.widget.ConstraintLayout>これで早速実行してみましょう。
画面右上で「Virtual Device」を選択して、実行ボタンを押下します。
しばらく待ってビルドが完了すると、無事にエミュレータが起動して文字が表示されました!!!わーい
プロジェクターに映して(おふとんから出ずに)デバッグする
せっかくなので、実機を使って(あったかいおふとん
から出ずに)デバッグしてみましょう。
Android TVの開発者向けオプションを設定
以下の通りに実行します。
https://developer.android.com/training/tv/start/start?hl=ja#runまずは、USBでPCをプロジェクターに接続します。
Android TV側で、「設定」を開き、「デバイス設定」を選択します。
「端末情報」を選択し、「ビルド」を連打します。
そのうち「開発者向けオプションが有効になりました」と表示されると思います。
「デバイス設定」に戻って、「開発者向けオプション」を選択し、「デバッグ」項目の「USBデバッグ」を有効にします。
Chrome OSのADBデバッグを有効にする
Chrome OS側で、ADBデバッグを有効にしておく必要があります。
以下に記載の手順でできます。
https://developer.android.com/topic/arc/development-environment?hl=ja#enable_adb_debugadbをインストールして、Android TV(実機)に接続する
Chrome OS側で、
adb
をインストールします。$ sudo apt install adb次に、実機のIPアドレスを確認して、接続します。
実機にUSBを接続した状態で、adb connect
します。$ adb connect [実機のIPアドレス]接続を終了するときは、
adb disconnect
で切断できます。$ adb disconnect [実機のIPアドレス]
adb connect
した状態で、Android StudioのRunning devicesの選択項目を見ると、実機が選択できるようになっています!
早速、実機で実行してみましょう。
映りました!!
(文字ちっっちゃ!!!!!!!)これであったかいおふとん
から出ずにデバッグできる環境が整いました。
メイン画面に時計を表示する
先ほど作成したメイン画面に、時計を表示してみたいと思います
![]()
画面上に、「日付」と「時刻」を表示できるようにします。
src/main/res/values/strings.xml
に、表示する文字列を追加します。app/src/main/res/values/strings.xml<resources> <string name="app_name">Time</string> <string name="date_format">yyyy/MM/dd</string> <string name="time_format">kk:mm:ss</string> </resources>
src/main/res/layout/activity_time.xml
を開き、TextView
を追加します。
先程追加した、@string/date
と@string/time
の文字列を表示するようにします。レイアウトはデザインタブで色々触ってみましたが、よくわかりませんでした。。
(けっこう難しいです)
まずは表示できるようにしたいので適当にパーツを作っていきます。
コードは以下のようになりました。app/src/main/res/layout/activity_time.xml<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".TimeActivity"> <TextView android:id="@+id/textDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="451dp" android:layout_marginTop="142dp" android:layout_marginEnd="451dp" android:text="@string/date_format" android:textAlignment="center" android:textSize="69sp" android:typeface="normal" app:layout_constraintBottom_toTopOf="@+id/textTime" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.756" /> <TextView android:id="@+id/textTime" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/time_format" android:textAlignment="center" android:textSize="69sp" android:typeface="normal" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.604" /> </androidx.constraintlayout.widget.ConstraintLayout>次に、
TimeActivity.kt
を以下のように変更して、上記で作成した@+id/textDate
と@+id/textTime
の文字を更新するようにします。app/src/main/java/io/github/kazenomachi/time/TimeActivity.ktclass TimeActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_time) // 時計 setTimer() } private fun setTimer() { // メインスレッドでHandlerのインスタンスを生成しておく val mainHandler: Handler = Handler(Looper.getMainLooper()); // 1秒ごとに時間を更新する timer("clock", period=1000) { // mainHandler(UI Thread)にテキストの更新を投げる mainHandler.post(Runnable() { // 現在日時を取得 val date = Date() // textDateに表示する日付を更新 findViewById<TextView>(R.id.textDate).text = DateFormat.format(getString(R.string.date_format), date).toString() // textTimeに表示する時刻を更新 findViewById<TextView>(R.id.textTime).text = DateFormat.format(getString(R.string.time_format), date).toString() }) } } }これで実行してみると、時計が表示されました
天気を表示する
OpenWeatherMapのAPIから、現在の天気を取得して表示してみたいと思います。
事前にアカウントを作成し、APIキーを取得しておきます。
まずは、APIにアクセスするための関数を定義します。
app/src/main/java/io/github/kazenomachi/time/TimeActivity.ktprivate fun fetch(url: URL, headers: Map<String, String> = mapOf()): String { // 受け取ったURLに接続する val connection = url.openConnection() as HttpURLConnection headers.forEach { (k, v) -> connection.setRequestProperty(k, v) } connection.connect() // 1行ずつ取得して文字列を返す val reader = BufferedReader(InputStreamReader(connection.inputStream)) val buffer = StringBuffer() var line: String? while (true) { line = reader.readLine() if (line == null) break buffer.append(line) } return buffer.toString() }次に、APIを叩いて文字列を変更する処理を作っていきます。
APIを叩くとJSONが返ってくるので、parseして取得した文字列を、先程の時計の実装と同様、TextView
に置き換えるようにします。app/src/main/java/io/github/kazenomachi/time/TimeActivity.ktprivate fun setCurrentWeather(mainHandler: Handler) { val apiKey = "APIキーを入れるよ" // イケてないけどとりあえず // 大阪市の緯度、経度 val latitude = "34.6937" val longitude = "135.5021" val url = URL("https://api.openweathermap.org/data/2.5/onecall?lat=$latitude&lon=$longitude&exclude=minutely,hourly&appid=$apiKey&lang=ja&units=metric") val executor: ExecutorService = Executors.newSingleThreadExecutor() executor.execute(Runnable() { // OpenWeatherMapのOne Call APIを叩いて、天気を取得する val result = fetch(url) val resultJSON = JSONObject(result) mainHandler.post(Runnable() { // 現在の天気 val current = resultJSON.getJSONObject("current") // 現在の気温 val currentTemp = current.getString("temp") // 取得した気温をビューのテキストに表示する findViewById<TextView>(R.id.currentTemp).text = "$currentTemp℃" }) }) }OpenWeatherMapでは、天気のアイコンも配信されています。
Picassoというライブラリを使って、配信されているアイコンを取得し、画面に表示しようと思います。PicassoはREADMEに記載のとおり、Gradleでインストールを行いました。
ソースコードを以下のように編集し、アイコンを表示できるようにします。
ついでに、日本語の現在の天気も表示できるようにしました。app/src/main/java/io/github/kazenomachi/time/TimeActivity.ktprivate fun setCurrentWeather(mainHandler: Handler) { val apiKey = "APIキーを入れるよ" // 大阪市の緯度、経度 val latitude = "34.6937" val longitude = "135.5021" val url = URL("https://api.openweathermap.org/data/2.5/onecall?lat=$latitude&lon=$longitude&exclude=minutely,hourly&appid=$apiKey&lang=ja&units=metric") // 5分ごとに現在の天気を更新する timer("clock", period=1000 * 60 * 5) { val executor: ExecutorService = Executors.newSingleThreadExecutor() executor.execute(Runnable() { // OpenWeatherMapのOne Call APIを叩いて、天気を取得する val result = fetch(url) val resultJSON = JSONObject(result) mainHandler.post(Runnable() { // 現在の天気 val current = resultJSON.getJSONObject("current") val weather = current.getJSONArray("weather").getJSONObject(0) // 現在の天気を表すアイコン val currentWeatherIcon = weather.getString("icon") val imgView: ImageView = findViewById(R.id.currentWeatherIcon) setWeatherIcon(currentWeatherIcon, imgView) // 現在の天気(日本語) val currentWeatherDescription = weather.getString("description") findViewById<TextView>(R.id.currentWeather).text = currentWeatherDescription // 現在の気温 val currentTemp = current.getString("temp") findViewById<TextView>(R.id.currentTemp).text = "$currentTemp℃" }) }) } } private fun setWeatherIcon(weather: String, imgView: ImageView) { // OpenWeatherMapのWeather iconsを取得し、表示する val fileName = "$weather@4x.png" val url = "https://openweathermap.org/img/wn/$fileName" // 指定したURLの画像に置き換える Picasso.get().load(url).into(imgView) }さいごに、上記で指定した内容を画面に反映します。
activity_time.xml
に、以下のコンポーネントを追加すると、天気が表示されるようになります!app/src/main/res/layout/activity_time.xml<!-- タイトル --> <TextView android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="68dp" android:layout_marginTop="4dp" android:text="WEATHER" android:textAlignment="viewStart" android:textColor="#8A817C" android:textSize="24sp" app:layout_constraintStart_toEndOf="@+id/textTime" app:layout_constraintTop_toTopOf="@+id/textDate" /> <!-- 天気アイコン --> <ImageView android:id="@+id/currentWeatherIcon" android:layout_width="200dp" android:layout_height="wrap_content" android:scaleType="centerCrop" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.142" app:layout_constraintStart_toEndOf="@+id/textTime" app:layout_constraintTop_toBottomOf="@+id/textView" app:layout_constraintVertical_bias="0.084" /> <!-- 気温 --> <TextView android:id="@+id/currentTemp" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAlignment="viewStart" android:textColor="#F4F3EE" android:textSize="56sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.126" app:layout_constraintStart_toEndOf="@+id/currentWeatherIcon" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.192" /> <!-- 現在の天気 --> <TextView android:id="@+id/currentWeather" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAlignment="viewStart" android:textColor="#F4F3EE" android:textSize="36sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.108" app:layout_constraintStart_toEndOf="@+id/currentWeatherIcon" app:layout_constraintTop_toBottomOf="@+id/currentTemp" app:layout_constraintVertical_bias="0.0" />TimeTreeの予定を表示する
TimeTree APIのPersonal access token(個人でAPIを試すことを目的としたアクセストークン)を使って予定を表示してみようと思います。
今回は予定の書き込みはしないので、読み取り権限のみのトークンを作成しました。
APIのトークンを作成したら、早速予定を取得してみたいと思います。
app/src/main/java/io/github/kazenomachi/time/TimeActivity.ktclass TimeActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_time) // メインスレッドでHandlerのインスタンスを生成しておく val mainHandler: Handler = Handler(Looper.getMainLooper()); // 時計 setTimer(mainHandler) // 天気 setCurrentWeather(mainHandler) // 予定を追加する setSchedule(mainHandler) } // 以下を追加 private fun setSchedule(mainHandler: Handler) { val timeTreeApiKey = "APIキーを指定するよ" val timeTreeCalendarId = "カレンダーのIDを指定するよ" // 1日分の予定を取得するようパラメータを指定 val url = URL("https://timetreeapis.com/calendars/$timeTreeCalendarId/upcoming_events?timezone=Asia/Tokyo&days=1&include=creator,label,attendees") val headers: Map<String, String> = mapOf( Pair("Accept", "application/vnd.timetree.v1+json"), Pair( "Authorization", "Bearer $timeTreeApiKey") ) val executor: ExecutorService = Executors.newSingleThreadExecutor() executor.execute(Runnable() { val result = fetch(url, headers) val resultJSON = JSONObject(result) val results = resultJSON.getJSONArray("data") var schedules: ArrayList<Schedule> = ArrayList() for (i in 0 until results.length()) { val schedule = results.getJSONObject(i) val attributes = schedule.getJSONObject("attributes") // 予定のタイトル val title = attributes.getString("title") // 終日予定かどうか val allDay = attributes.getBoolean("all_day") // 開始日時 val startAt = attributes.getString("start_at") // 終了日時 val endAt = attributes.getString("end_at") // 開始日時、終了日時のフォーマットをHH:mmの形式にする val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")) val timeFormat = SimpleDateFormat("HH:mm") timeFormat.setTimeZone(TimeZone.getTimeZone("Asia/Tokyo")) val startTime: String? = timeFormat.format(dateFormat.parse(startAt)).toString() val endTime: String? = timeFormat.format(dateFormat.parse(endAt)).toString() // Scheduleのデータクラスを作成し、schedulesのArrayListに追加する schedules.add(Schedule(title, startTime, endTime, allDay)) } }) }予定のデータクラスも作成しておきます。
app/src/main/java/io/github/kazenomachi/time/Schedule.ktdata class Schedule(val title: String, val startTime: String?, val endTime: String?, val allDay: Boolean)上記で取得した予定を画面に表示したいのですが、予定は他の項目と違い、表示する項目数が決まっていません。
そのため、今までのように
TextView
の文字を置き換えるという方法ではなく、RecyclerView
というウィジェットを使って予定を表示します。
RecyclerView
は少し難しい概念で挫折しかけたのですが、こちらの動画を見て概念を理解できました。
Android Kotlin RecyclerView Tutorial
(この記事ではRecyclerView
について詳しく解説はしないので、最初に見ておくと分かりやすいかもしれません)まずは、レイアウトに
RecyclerView
を追加します。app/src/main/res/layout/activity_time.xml<TextView android:id="@+id/todaySchedule" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="44dp" android:text="TODAY'S SCHEDULE" android:textAlignment="viewStart" android:textColor="#8A817C" android:textSize="24sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textTime" app:layout_constraintVertical_bias="0.167" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/schedulesView" android:layout_width="982dp" android:layout_height="204dp" android:layout_marginVertical="10dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/todaySchedule" app:layout_constraintVertical_bias="0.558" />リスト内に表示する子項目のレイアウトも追加します。
app/src/main/res/layout/schedule.xml<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingHorizontal="42dp" android:paddingVertical="10dp" android:focusable="true"> <TextView android:id="@+id/startTime" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#BCB8B1" android:textSize="14sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.0" /> <TextView android:id="@+id/endTime" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#BCB8B1" android:textSize="14sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/startTime" app:layout_constraintVertical_bias="1.0" /> <View android:id="@+id/verticalLine" android:layout_width="3dp" android:layout_height="34sp" android:background="#E0CA3C" android:layout_marginVertical="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.011" app:layout_constraintStart_toEndOf="@+id/startTime" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAlignment="viewStart" android:textColor="#BCB8B1" android:textSize="28sp" android:layout_marginLeft="20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toEndOf="@+id/verticalLine" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="1.0" /> </androidx.constraintlayout.widget.ConstraintLayout>
TimeActivity.kt
で追加したsetSchedule()
内で、上記で追加したRecyclerViewを読み込んで、Adapterを設定します。app/src/main/java/io/github/kazenomachi/time/TimeActivity.ktprivate fun setSchedule(mainHandler: Handler) { // 〜省略〜 val executor: ExecutorService = Executors.newSingleThreadExecutor() executor.execute(Runnable() { // 〜省略〜 for (i in 0 until results.length()) { // 〜省略〜 } // 以下を追加 mainHandler.post(Runnable() { findViewById<RecyclerView>(R.id.schedulesView).also { recyclerView: RecyclerView -> recyclerView.adapter = SchedulesAdapter(this, schedules) recyclerView.layoutManager = LinearLayoutManager(this) } }) }) }
Adapter
は以下のように作成します。app/src/main/java/io/github/kazenomachi/time/SchedulesAdapter.ktclass SchedulesAdapter(private val context: Context, private val schedules: ArrayList<Schedule>) : RecyclerView.Adapter<SchedulesAdapter.SchedulesViewHolder>() { class SchedulesViewHolder(view: View) : RecyclerView.ViewHolder(view) { // 反映するコンポーネントをViewHolderに定義する val startTime: TextView = view.findViewById(R.id.startTime) val endTime: TextView = view.findViewById(R.id.endTime) val title: TextView = view.findViewById(R.id.title) } // ViewHolderを作る override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SchedulesViewHolder = SchedulesViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.schedule, parent, false)) // 表示する項目数を返す override fun getItemCount(): Int = schedules.size // 1つの項目に表示する内容を定義する override fun onBindViewHolder(holder: SchedulesViewHolder, position: Int) { // 予定のタイトル holder.title.text = schedules[position].title if (schedules[position].allDay) { // 終日予定の場合 holder.startTime.text = "終日" holder.endTime.text = "" holder.endTime.visibility = View.INVISIBLE } else { // 開始時刻、終了時刻が指定されている予定の場合 holder.startTime.text = schedules[position].startTime holder.endTime.text = schedules[position].endTime } } }ここまでできたら、画面上に予定が表示されます!
開発したアプリを実機にインストールして使う
最後に、今回開発したアプリを実機にインストールできるようにします。
ビルドしてAPKファイルを作成し、インストールする流れは通常のAndroidアプリと同じです。
方法は色々ありますが、私はadbコマンドでインストールしました。私の場合、これだけではホーム画面に表示されず、詰まってしまいました。
確認したところ、インテントフィルタの指定が誤っていたようなので、以下の通り修正したところ、無事にホーム画面にアプリが表示されるようになりました!
app/src/main/AndroidManifest.xml- <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" />(公式ガイドの一番最初にしっかり書いてありましたが完全に見落としていました
)
https://developer.android.com/training/tv/start/start?hl=ja
今回はアイコンを作っていないのですが、アイコンを作ればこの画面にも反映されます。さいごに
普段はRailsでWebのバックエンドの開発をしており、アプリ開発は未知の世界でした。
でも、想像していたよりも難しくなく、楽しく開発できました。レイアウト周りが少し難しく感じましたが、もう少し理解が進んだらもっとおもしろいアプリを開発できそうです。
今回書いたソースコードはGitHubに公開しています。
https://github.com/kazenomachi/Time
皆さんもこの冬、自分だけのダッシュボードを作ってみてはいかがでしょうか![]()
プロジェクターは生活を豊かにするので、自分へのクリスマスプレゼントにおすすめです![]()
![]()
この記事を書くにあたり、あったかいおふとん
で開発を行い、執筆しました。
あまりあったかいおふとんから出ない生活を送っていると、体を痛めるので、時々運動することをおすすめします。
私は肩と腰を痛めました。参考にしたサイトなど
- 投稿日:2020-12-18T10:08:37+09:00
AndroidでOne Touch(確認無し)で電話する方法(Java)
ネットで、確認なしで電話をかける方法をいくら調べても、
Intent intent = new Intent(Intent.ACTION_DIAL, "tel:0123345678);
のACTION_DIALのところを
Intent intent = new Intent(Intent.ACTION_CALL, "tel:012345678");
のようにACTION_CALLにするだけでOKと書いてあるのに、実行するとなぜか落ちる(?_?)もちろん、AbdroidManifest.xmlに、
uses-permission android:name="android.permission.CALL_PHONE"
を入れるのも忘れてないし、なぜだろうと悩んだところ、
アプリにCALL_PHONEへのアクセス権を与えてやらなければいけないことが判明。アプリ起動時に、以下のようにアクセス権を与える許可ダイアログを表示してやらなければならなかった。
CALL_PHONEはdangerousパーミッションだから、ユーザにいちいち許可をもらわなければならないのね(^^;)public class MainActivity extends AppCompatActivity {
static final int REQUEST_CODE = 1;@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CALL_PHONE)==PackageManager.PERMISSION_DENIED) { ActivityCompat.requestPermissions( this, new String[] { Manifest.permission.CALL_PHONE }, REQUEST_CODE); } @Override public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) { if (requestCode == REQUEST_CODE) { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { // Dangerous パーミッションのリクエストが許可 } else { Toast toast = Toast.makeText(getApplicationContext(), "電話機能のアクセス権限を追加しないと使えません", Toast.LENGTH_LONG); toast.show(); finish(); } } }
- 投稿日:2020-12-18T07:43:19+09:00
AndroidアプリにVisual Regression Test導入を目指す 第一回 Android instrumented testで自動でスクショを撮る
この記事は Voicy Advent Calendar 2020 の17日目の記事です。
前日は @yamagenii さんの Goでmainが実行される様子を追ってみる でした。はじめに
2日目に同じAndroidチームの ぬまさん(@moonum)がUnit testの内容を書いてくれたように、VoicyのAndroidチームでは「テストをしっかり書いていこう!」という空気感になりつつあります。
我々Androidチームは全員が8月9月にジョインしたばかりなのですが、いざプロジェクトファイルを覗いてみるとほとんどテストコードが無い…!!ということが発覚したのが発端です。
テストコードがほとんど無くともVoicyのアプリの品質が一定以上を維持されていたのは、ひとえにQAの方々の高品質なチェックによるものだったのです。
(事実、リリース前の動作確認では「本当に人力で見つけたのか?」と思うようなレアケースの不具合を検出していただくことも少なくありません)どんなにテストをしっかり実装したところで最終的な人力チェックは確実に必要ではありますが、できるだけ自動化できるところは自動化して、手動でしか確認できないところに注力していただきたいところ。
そんなこんな思っているときに、先日ぬまさんにVisual Regression Testをちらっと教えてもらいました。
確かに自動でスクショ撮って修正前後で比較すればデグレチェックが一気に楽になる…!というわけで今後VoicyのAndroidアプリでVisual Regression Testを導入するとなったときに先駆者となれるよう、お試しで動かしてみることにしました!
今回やること
「Visual Regression Test」を導入するにあたって、大きく工程を分けると
- 画面ごとのスクリーンショットの自動化
- 画像比較の自動化
に分かれます。今回は前半戦ということで、スクショをテストコード上で取得していきますよ〜
先駆者の方がいらっしゃったので、その記事を見つつ試しました。
『Finnovalley Developers Blog - MoneyEasyのAndroidアプリでVisual Regression Testを始めた話(実装編)』
https://finnovalley.hatenablog.com/entry/2020/11/18/112425基本的にはこちらの記事の内容を踏まえつつ、詰まったところをつけたそうと思います。
(ちなみにタイトルに第一回と書いたとおり、続きます…)
手順
ライブラリインポート
今回スクリーンショットを撮るにあたって、Firebase Test Lab利用者向け?っぽいライブラリ「cloudtestingscreenshotter」を使用します。
Firebase公式に貼ってあるaarをプロジェクトに抱える形で参照します。公式ドキュメントで書いてある通りにaarsディレクトリを作ってbuild.gradleを編集していくとなぜかうまいこと参照してくれなかったので、いつもやってる下記の方法を取りました。
- File -> New -> New Module -> Import .JAR/.AAR Package -> aarsにおいたライブラリのaarファイルを選択
- File -> Project Structure -> Dependencies から依存関係を追加
パーミッション付与
個人的につまったポイントなのですが、このライブラリを使用すると端末内の
sdcard/screenshots
にスクショが保存されるはずが、全然保存されない…
公式のとおりにパーミッション付与してだめだったのですが、どうやらTargetSdkVersionをいくつにするかによって対応が変わってくるようです。
当初Targetを30に設定していたのですが、Android 10(APIレベル 29) から外部ストレージの扱いが変わった影響でいろいろとややこしくなってました。うまいことやらないとFileNotFoundExceptionが発生してスクショが保存されません。
そして私はその例外を処理中でキャッチしていなかったのでずっと例外に気づきませんでした…
- APIレベル28以下をTargetとする場合
公式に書いてある通り、マニフェストにパーミッションを記述すれば動きます。
<manifest ... > ... <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <!-- 公式にはINTERNETも書いてあるけど別になくても動く --/> ... </manifest>
- APIレベル29以上をTargetにする場合
applicationタグ内に
android:requestLegacyExternalStorage
を追加する必要があるようです。
公式には全く書いてないので気づきませんでした…<application .... .... android:requestLegacyExternalStorage="true" >細かいことはこちらを読んでみてください
Flutter で外部ストレージにアクセスするアプリを作るときは Android バージョンを考慮して『ひと手間』いるから注意してくれよな!InstrumentedTest実装
今回はアプリプロジェクト作成時のデフォルトのプロジェクトファイルを使用します。画面遷移したかったのでFragmentをもっているBase Activityを選択。
テストもデフォルトで作成されるテストファイルに追記しました。import androidx.navigation.findNavController import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.android.libraries.cloudtesting.screenshots.ScreenShotter import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @get:Rule var scenarioRule = ActivityScenarioRule(MainActivity::class.java) @Before fun navigateToConfirm() { scenarioRule.scenario.onActivity { activity -> // 画面遷移しておく場合 activity.findNavController(R.id.nav_host_fragment).navigate(R.id.action_FirstFragment_to_SecondFragment) } } @Test fun test() { scenarioRule.scenario.onActivity { activity -> ScreenShotter.takeScreenshot("secondFragment", activity) } } .... .... }あとはこの遷移とtakeScreenshotをベースに、各画面の処理を繰り返し書いていきます。
Test実行
AndroidStudio上で実行してもスクショは保存されますが、CIを構築することを考えてgradleコマンドで実行してみます。
./gradlew connectedAndroidTest端末内の
sdcard/screenshots
にスクショが保存されます。
上記の実装だとUnknownTestClass-unknownTestMethod-secondFragment-1.jpg
になりました。
察するにテストクラス名やメソッド名がファイル名になるのですが、取得できていないようですね…
ここら辺は追々明らかにしたいところです。感想
今回はアプリプロジェクト作成時にデフォルトでAndroid Architecture ComponentsのNavigationが設定されていたり、遷移時にIntentを渡す必要がなかったりするのですらっと書いてしまいましたが、実際に画面遷移も描くとなると結構ボリュームが出てきそうです。実際のアプリプロジェクトでもっと色々試してみようと思います。
本当は画像比較の自動化もひとつの記事内で取り上げたかったのですが、ボリュームがでてきたのと時間の都合で別記事に分けます!
次回は @somen440 さんです!
- 投稿日:2020-12-18T07:43:19+09:00
AndroidアプリにVisual Regression Test導入を目指す 第一回 Instrumented testで自動でスクショを撮る
この記事は Voicy Advent Calendar 2020 の17日目の記事です。
前日は @yamagenii さんの Goでmainが実行される様子を追ってみる でした。はじめに
2日目に同じAndroidチームの ぬまさん(@moonum)がUnit testの内容を書いてくれたように、VoicyのAndroidチームでは「テストをしっかり書いていこう!」という空気感になりつつあります。
我々Androidチームは全員が8月9月にジョインしたばかりなのですが、いざプロジェクトファイルを覗いてみるとほとんどテストコードが無い…!!ということが発覚したのが発端です。
テストコードがほとんど無くともVoicyのアプリの品質が一定以上を維持されていたのは、ひとえにQAの方々の高品質なチェックによるものだったのです。
(事実、リリース前の動作確認では「本当に人力で見つけたのか?」と思うようなレアケースの不具合を検出していただくことも少なくありません)どんなにテストをしっかり実装したところで最終的な人力チェックは確実に必要ではありますが、できるだけ自動化できるところは自動化して、手動でしか確認できないところに注力していただきたいところ。
そんなこんな思っているときに、先日ぬまさんにVisual Regression Testをちらっと教えてもらいました。
確かに自動でスクショ撮って修正前後で比較すればデグレチェックが一気に楽になる…!というわけで今後VoicyのAndroidアプリでVisual Regression Testを導入するとなったときに先駆者となれるよう、お試しで動かしてみることにしました!
今回やること
「Visual Regression Test」を導入するにあたって、大きく工程を分けると
- 画面ごとのスクリーンショットの自動化
- 画像比較の自動化
に分かれます。今回は前半戦ということで、スクショをテストコード上で取得していきますよ〜
先駆者の方がいらっしゃったので、その記事を見つつ試しました。
『Finnovalley Developers Blog - MoneyEasyのAndroidアプリでVisual Regression Testを始めた話(実装編)』
https://finnovalley.hatenablog.com/entry/2020/11/18/112425基本的にはこちらの記事の内容を踏まえつつ、詰まったところをつけたそうと思います。
(ちなみにタイトルに第一回と書いたとおり、続きます…)
手順
ライブラリインポート
今回スクリーンショットを撮るにあたって、Firebase Test Lab利用者向け?っぽいライブラリ「cloudtestingscreenshotter」を使用します。
Firebase公式に貼ってあるaarをプロジェクトに抱える形で参照します。公式ドキュメントで書いてある通りにaarsディレクトリを作ってbuild.gradleを編集していくとなぜかうまいこと参照してくれなかったので、いつもやってる下記の方法を取りました。
- File -> New -> New Module -> Import .JAR/.AAR Package -> aarsにおいたライブラリのaarファイルを選択
- File -> Project Structure -> Dependencies から依存関係を追加
パーミッション付与
個人的につまったポイントなのですが、このライブラリを使用すると端末内の
sdcard/screenshots
にスクショが保存されるはずが、全然保存されない…
公式のとおりにパーミッション付与してだめだったのですが、どうやらTargetSdkVersionをいくつにするかによって対応が変わってくるようです。
当初Targetを30に設定していたのですが、29(Android 10)から外部ストレージの扱いが変わった影響でいろいろとややこしくなってました。うまいことやらないとFileNotFoundExceptionが発生してスクショが保存されません。
そして私はその例外を処理中でキャッチしていなかったのでずっと例外に気づきませんでした…
- APIレベル28以下をTargetとする場合
公式に書いてある通り、マニフェストにパーミッションを記述すれば動きます。
<manifest ... > ... <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <!-- 公式にはINTERNETも書いてあるけど別になくても動く --/> ... </manifest>
- APIレベル29以上をTargetにする場合
applicationタグ内に
android:requestLegacyExternalStorage
を追加する必要があるようです。
公式には全く書いてないので気づきませんでした…<application .... .... android:requestLegacyExternalStorage="true" >細かいことはこちらを読んでみてください
Flutter で外部ストレージにアクセスするアプリを作るときは Android バージョンを考慮して『ひと手間』いるから注意してくれよな!InstrumentedTest実装
今回はアプリプロジェクト作成時のデフォルトのプロジェクトファイルを使用します。画面遷移したかったのでFragmentをもっているBase Activityを選択。
テストもデフォルトで作成されるテストファイルに追記しました。import androidx.navigation.findNavController import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.android.libraries.cloudtesting.screenshots.ScreenShotter import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @get:Rule var scenarioRule = ActivityScenarioRule(MainActivity::class.java) @Before fun navigateToConfirm() { scenarioRule.scenario.onActivity { activity -> // 画面遷移しておく場合 activity.findNavController(R.id.nav_host_fragment).navigate(R.id.action_FirstFragment_to_SecondFragment) } } @Test fun test() { scenarioRule.scenario.onActivity { activity -> ScreenShotter.takeScreenshot("secondFragment", activity) } } .... .... }あとはこの遷移とtakeScreenshotをベースに、各画面の処理を繰り返し書いていきます。
Test実行
AndroidStudio上で実行してもスクショは保存されますが、CIを構築することを考えてgradleコマンドで実行してみます。
./gradlew connectedAndroidTest端末内の
sdcard/screenshots
にスクショが保存されます。
上記の実装だとUnknownTestClass-unknownTestMethod-secondFragment-1.jpg
になりました。
察するにテストクラス名やメソッド名がファイル名になるのですが、取得できていないようですね…
ここら辺は追々明らかにしたいところです。感想
今回はアプリプロジェクト作成時にデフォルトでAndroid Architecture ComponentsのNavigationが設定されていたり、遷移時にIntentを渡す必要がなかったりするのですらっと書いてしまいましたが、実際に画面遷移も描くとなると結構ボリュームが出てきそうです。実際のアプリプロジェクトでもっと色々試してみようと思います。
本当は画像比較の自動化もひとつの記事内で取り上げたかったのですが、ボリュームがでてきたのと時間の都合で別記事に分けます!
次回は @somen440 さんです!