- 投稿日:2019-12-11T23:39:30+09:00
play-services-oss-licenses で表示する一覧画面と詳細画面の Toolbar のタイトルの色を白くする
アプリ内で使用している OSS の一覧と詳細を表示している Activity の Toolbar のタイトルの色を白くしたく、いろいろと試行錯誤しました。
いつの日かの自分の参考のための備忘録です。実装の詳しい手順に関しては以下の記事が参考になりますので割愛させていただきます。
https://qiita.com/sho5nn/items/f63ebd7ccc0c86d98e4b公式URL
https://developers.google.com/android/guides/opensource結論
そこまで難しいことは無いのですが、結論から先に言うと、
DarkActionBar を parent に持つ style を定義してあげる
です。以下コードです。
まずは OssLicensesActivity と OssLicensesMenuActivity に適用する style を作成します。<style name="AppTheme.OssLicenses" parent="Theme.AppCompat.Light.DarkActionBar"> <item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorAccent">@color/colorAccent</item> </style>そして上記の AppTheme.OssLicenses をそれぞれの Activity の Theme に適用してあげれば良いです。
<activity android:name="com.google.android.gms.oss.licenses.OssLicensesActivity" android:theme="@style/AppTheme.OssLicenses"> </activity> <activity android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity" android:theme="@style/AppTheme.OssLicenses" > </activity>これで Toolbar のタイトルの色は白くなっているはずです。
最後に
Android のテーマは奥が深いのでもっと調べないとです。。
- 投稿日:2019-12-11T23:21:27+09:00
JetPack Security を触ってみる
Jetpack Security とは
- Google I/O 2019で発表されたのセキュリティライブラリ
- 既存のAES暗号化・復号化に比べて安易
- 暗号化する際のパフォーマンスが優れている
EncryptedFile
、EncryptedSharedPreferences
がメインのクラスとしてあり、SharedPreferenceとファイルI/OをサポートEncryptedFile
では googleが出してる暗号化ライブラリ tinkのロジックが内包されている。- min SDK version 23
Master Keyの作成
Jetpack Securityは、キー管理に2部構成のシステムを使用します。
ファイルまたは共有設定データを暗号化するための 1つ以上のキーを含むキーセット。キーセット自体は SharedPreferences に格納されます。
すべてのキーセットを暗号化するマスターキー。このキーは Android キーストア システムを使用して保存されます。
Android Keystoreのラッパーclassである
MasterKeys
を使用するとたった2行でMaster Keyを作成出来る。val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec)EncryptedSharedPreferencesを触ってみる
通常のSharedPreferences
val data = getSharedPreferences("Sample", Context.MODE_PRIVATE) val editor = data.edit() editor.putInt("IntSave", 10) editor.apply() val intSaved = data.getInt("IntSave", 1) Log.d("IntSave", intSaved.toString())EncryptedSharedPreferences
val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec) val sharedPreferences = EncryptedSharedPreferences .create( "Sample", masterKeyAlias, context, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) val editor = sharedPreferences.edit() editor.putInt("IntSave", 10) editor.apply() val intSaved = sharedPreferences.getInt("IntSave", 1) Log.d("IntSave", intSaved.toString())速度を比較してみる
SharedPreferenceTestのテストコード
@RunWith(AndroidJUnit4::class) class SharedPreferenceTest { private lateinit var data: SharedPreferences private lateinit var editor: SharedPreferences.Editor @Before fun setup() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext data = appContext.getSharedPreferences("Sample", Context.MODE_PRIVATE) editor = data.edit() } @Test fun sharedPreference() { for (i in 1..10000) { editor.putInt("IntSave", i) editor.apply() val intSaved = data.getInt("IntSave", 1) assertEquals(intSaved, i) } } }EncryptedSharedPreferenceのテストコード
@RunWith(AndroidJUnit4::class) class EncryptedSharedPreferenceTest { private lateinit var data: SharedPreferences private lateinit var editor: SharedPreferences.Editor @Before fun setup() { val appContext = InstrumentationRegistry.getInstrumentation().targetContext val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec) data = EncryptedSharedPreferences .create( "Sample", masterKeyAlias, appContext, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) editor = data.edit() } @Test fun encryptedSharedPreference() { for (i in 1..10000) { editor.putInt("IntSave", i) editor.apply() val intSaved = data.getInt("IntSave", 1) Assert.assertEquals(intSaved, i) } } }
測定結果 EncryptedSharedPreferences SharedPreferences 最遅 6212 567 平均 6119.3 514.3 最速 6028 426 単位 ms 実行環境 Pixel 3
比べてみると10倍の差が開いていますが、EncryptedSharedPreferencesも十分早いパフォーマンスが出ますね。
EncryptedFileを触ってみる
Write File
MY SUPER SECRET INFORMATION
という内容のtxtファイルを書き込んでみる。val fileToWrite = "my_other_sensitive_data.txt" val encryptedFile = EncryptedFile.Builder( File(context.getFilesDir(), fileToWrite), context, masterKeyAlias, EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB ).build() // Write to a file. try { val outputStream: FileOutputStream? = encryptedFile.openFileOutput() outputStream?.apply { write("MY SUPER SECRET INFORMATION" .toByteArray(Charset.forName("UTF-8"))) flush() close() } } catch (ex: IOException) { // Error occurred opening file for writing. }Read File
MY SUPER SECRET INFORMATION
という内容がログに出力され、ちゃんと読める様になっている。
BufferedReaderだけで読み込むと、(�]�}�Wr<������q1Bv����B��|)��j_��>��uBLN#���Y�w���;�̴?�w��M���;�K�M�Ƕ�
と表示され、しっかりとencryptされているのがわかる。val fileToRead = "my_sensitive_data.txt" lateinit var byteStream: ByteArrayOutputStream val encryptedFile = EncryptedFile.Builder( File(context.getFilesDir(), fileToRead), context, masterKeyAlias, EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB ).build() try { encryptedFile.openFileInput().use { fileInputStream -> try { val sb = StringBuilder() val br = BufferedReader(InputStreamReader(fileInputStream) as Reader?) br.readLine() .forEach { sb.append(it) } br.close() // MY SUPER SECRET INFORMATIONと出力される Log.d("fileContents", sb.toString()) } catch (ex: Exception) { // Error occurred opening raw file for reading. } finally { fileInputStream.close() } } } catch (ex: IOException) { // Error occurred opening encrypted file for reading. }
- 投稿日:2019-12-11T23:21:27+09:00
JetPack Secure を触ってみる
Jetpack Secure とは
- Google I/O 2019で発表されたのセキュリティライブラリ
- 既存のAES暗号化・復号化に比べて安易
- 暗号化する際のパフォーマンスが優れている
EncryptedFile
、EncryptedSharedPreferences
がメインのクラスとしてあり、SharedPreferenceとファイルI/OをサポートEncryptedFile
では googleが出してる暗号化ライブラリ tinkのロジックが内包されている。- min SDK version 23
Master Keyの作成
Jetpack Secureは、キー管理に2部構成のシステムを使用します。
ファイルまたは共有設定データを暗号化するための 1つ以上のキーを含むキーセット。キーセット自体は SharedPreferences に格納されます。
すべてのキーセットを暗号化するマスターキー。このキーは Android キーストア システムを使用して保存されます。
Android Keystoreのラッパーclassである
MasterKeys
を使用するとたった2行でMaster Keyを作成出来る。val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec)EncryptedSharedPreferencesを触ってみる
通常のSharedPreferences
val data = getSharedPreferences("Sample", Context.MODE_PRIVATE) val editor = data.edit() editor.putInt("IntSave", 10) editor.apply() val intSaved = data.getInt("IntSave", 1) Log.d("IntSave", intSaved.toString())EncryptedSharedPreferences
val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec) val sharedPreferences = EncryptedSharedPreferences .create( "Sample", masterKeyAlias, context, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) val editor = sharedPreferences.edit() editor.putInt("IntSave", 10) editor.apply() val intSaved = sharedPreferences.getInt("IntSave", 1) Log.d("IntSave", intSaved.toString())速度を比較してみる
SharedPreferenceTestのテストコード
@RunWith(AndroidJUnit4::class) class SharedPreferenceTest { private lateinit var data: SharedPreferences private lateinit var editor: SharedPreferences.Editor @Before fun setup() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext data = appContext.getSharedPreferences("Sample", Context.MODE_PRIVATE) editor = data.edit() } @Test fun sharedPreference() { for (i in 1..10000) { editor.putInt("IntSave", i) editor.apply() val intSaved = data.getInt("IntSave", 1) assertEquals(intSaved, i) } } }EncryptedSharedPreferenceのテストコード
@RunWith(AndroidJUnit4::class) class EncryptedSharedPreferenceTest { private lateinit var data: SharedPreferences private lateinit var editor: SharedPreferences.Editor @Before fun setup() { val appContext = InstrumentationRegistry.getInstrumentation().targetContext val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec) data = EncryptedSharedPreferences .create( "Sample", masterKeyAlias, appContext, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) editor = data.edit() } @Test fun encryptedSharedPreference() { for (i in 1..10000) { editor.putInt("IntSave", i) editor.apply() val intSaved = data.getInt("IntSave", 1) Assert.assertEquals(intSaved, i) } } }
測定結果 EncryptedSharedPreferences SharedPreferences 最遅 6212 567 平均 6119.3 514.3 最速 6028 426 単位 ms 実行環境 Pixel 3
比べてみると10倍の差が開いていますが、EncryptedSharedPreferencesも十分早いパフォーマンスが出ますね。
EncryptedFileを触ってみる
Write File
MY SUPER SECRET INFORMATION
という内容のtxtファイルを書き込んでみる。val fileToWrite = "my_other_sensitive_data.txt" val encryptedFile = EncryptedFile.Builder( File(context.getFilesDir(), fileToWrite), context, masterKeyAlias, EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB ).build() // Write to a file. try { val outputStream: FileOutputStream? = encryptedFile.openFileOutput() outputStream?.apply { write("MY SUPER SECRET INFORMATION" .toByteArray(Charset.forName("UTF-8"))) flush() close() } } catch (ex: IOException) { // Error occurred opening file for writing. }Read File
MY SUPER SECRET INFORMATION
という内容がログに出力され、ちゃんと読める様になっている。
BufferedReaderだけで読み込むと、(�]�}�Wr<������q1Bv����B��|)��j_��>��uBLN#���Y�w���;�̴?�w��M���;�K�M�Ƕ�
と表示され、しっかりとencryptされているのがわかる。val fileToRead = "my_sensitive_data.txt" lateinit var byteStream: ByteArrayOutputStream val encryptedFile = EncryptedFile.Builder( File(context.getFilesDir(), fileToRead), context, masterKeyAlias, EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB ).build() try { encryptedFile.openFileInput().use { fileInputStream -> try { val sb = StringBuilder() val br = BufferedReader(InputStreamReader(fileInputStream) as Reader?) br.readLine() .forEach { sb.append(it) } br.close() // MY SUPER SECRET INFORMATIONと出力される Log.d("fileContents", sb.toString()) } catch (ex: Exception) { // Error occurred opening raw file for reading. } finally { fileInputStream.close() } } } catch (ex: IOException) { // Error occurred opening encrypted file for reading. }
- 投稿日:2019-12-11T23:14:34+09:00
ViewPager2でスワイプを無効にする方法
- 投稿日:2019-12-11T23:07:55+09:00
【Androidアプリ】音声テキスト変換アプリが完成♪
苦節三週間。。。
遂にスマホアプリが出来た?やったこと
・ボタン名整理
・ボタン処理時のバグ取り
・ファイル名取得・ボタン名整理
ボタン名等を以下のとおりとした。
①保存File名を入力してください 音声テキスト変換したテキストをこのファイルに保存します。 ②削除するFile名を入力してください このファイルを削除します。 ③音声入力してください この文言の下のテキストエリアにカーソルを合わせて、Google音声入力としたあと、音声入力します。 ④保存ボタン このボタンを押すと上で定義したファイルにテキストが保存される。 ⑤表示ボタン このボタンを押すと上記の保存Fileに格納されているテキストが表示される。 ⑥削除Fileボタン このボタンを押すと、上記の削除するFile名に記載されたファイルが削除される。 また、何もファイル名がないあるいは、存在しないファイル名の場合は、存在するファイル名一覧を表示する。 ⑦クリア 音声入力エリアと表示エリアをクリアします。 ただし、保存File名と削除File名はクリアしません。 ※ここもクリアできますが残した方が使いやすいと思いますstrings.xml<?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">Speech to Text</string> <string name="file_name">保存File名を入力してください</string> <string name="del_file_name">削除するFile名を入力してください</string> <string name="tv_name">音声入力してください</string> <string name="bt_click">保存</string> <string name="bt_click1">表示</string> <string name="bt_click2">File削除</string> <string name="bt_clear">クリア</string> </resources>・ボタン処理時のバグ取り
削除Fileが存在しないのに削除しようとするとバグってアプリが落ちてしまいます。
そこで、それを検知して以下のように回避しました。
【参考】
Null Safetyval file1 = File(pathUtf8+"/"+ inputStr_delfile) if (file1 != null && file1.length() > 0) { file1.delete() } else { //file.delete() }・ファイル名取得
削除Fileボタンを押したときの振舞いが以下のとおりです。
削除部分のコードは上記のとおりですが、それ以外の部分でファイル一覧を表示しています。
ここでは以下の3つを参考としています。
【参考】
①【Android/Java】アプリ内のファイルの操作
②Using Replace function in Kotlin
③Kotlinの文字列を連結する
すなわち①ファイルが存在するPathを取得し、ファイル名の一覧を取得する
var pathUtf8 = getFilesDir().getAbsolutePath(); //ファイル名の一覧を取得する val file = File(pathUtf8) val files = file.listFiles()②③ファイルのPathを””に置き換えてファイル名のみにし、取得したファイル一覧を表示する
※コードは短いけど適度にハマりました
//取得した一覧を表示する var f ="" for (i in files.indices) { val fi= files[i] val files_: String = fi.toString().replace( pathUtf8+ "/" ,"") f = "ファイル" + (i + 1) +" "+ files_ +"\n"+ f } output.text = fこの部分のコード
R.id.btClick2 -> { var pathUtf8 = getFilesDir().getAbsolutePath(); //ファイル名の一覧を取得する val file = File(pathUtf8) val files = file.listFiles() //取得した一覧を表示する var f ="" for (i in files.indices) { val fi= files[i] val files_: String = fi.toString().replace( pathUtf8+ "/" ,"") f = "ファイル" + (i + 1) +" "+ files_ +"\n"+ f } output.text = f val file1 = File(pathUtf8+"/"+ inputStr_delfile) if (file1 != null && file1.length() > 0) { file1.delete() } else { //file.delete() } }完成イメージ
長いファイル名 多様な言語 まとめ
・音声テキスト変換アプリが完成した
・ファイル名は何語でも長くても使えた・GooglePlayに登録しようと思う
おまけ
MainActivity.ktpackage com.example.hellosample import android.content.Context import android.os.Bundle import android.view.View import android.widget.Button import android.widget.EditText import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import java.io.File import java.text.SimpleDateFormat import java.util.* class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) //表示ボタンであるButtonオブジェクトを取得。 val btClick = findViewById<Button>(R.id.btClick) //表示ボタンであるButtonオブジェクトを取得。 val btClick1 = findViewById<Button>(R.id.btClick1) //表示ボタンであるButtonオブジェクトを取得。 val btClick2 = findViewById<Button>(R.id.btClick2) //リスナクラスのインスタンスを生成。 val listener = HelloListener() //表示ボタンにリスナを設定。 btClick.setOnClickListener(listener) //表示ボタンにリスナを設定。 btClick1.setOnClickListener(listener) //表示ボタンにリスナを設定。 btClick2.setOnClickListener(listener) //クリアボタンであるButtonオブジェクトを取得。 val btClear = findViewById<Button>(R.id.btClear) //クリアボタンにリスナを設定。 btClear.setOnClickListener(listener) } /** * ボタンをクリックしたときのリスナクラス。 */ private inner class HelloListener : View.OnClickListener { override fun onClick(view: View) { //名前入力欄であるEditTextオブジェクトを取得。 val input = findViewById<EditText>(R.id.etName) //名前入力欄であるEditTextオブジェクトを取得。 val input_file = findViewById<EditText>(R.id.fileName) //名前入力欄であるEditTextオブジェクトを取得。 val input_delfile = findViewById<EditText>(R.id.delfileName) //メッセージを表示するTextViewオブジェクトを取得。 val output = findViewById<TextView>(R.id.tvOutput) //入力された名前文字列を取得。 val inputStr = input.text.toString() //入力された名前文字列を取得。 val inputStr_file = input_file.text.toString() //入力された名前文字列を取得。 val inputStr_delfile = input_delfile.text.toString() val df = SimpleDateFormat("HH:mm:ss") //"yyyy/MM/dd HH:mm:ss" val date = Date() //input_count.setText("0") //idのR値に応じて処理を分岐。 when(view.id) { //表示ボタンの場合… R.id.btClick -> { //入力された名前文字列を取得。 val inputStr = input.text.toString() //入力された名前文字列を取得。 val inputStr_file = input_file.text.toString() //メッセージを表示。 val inputStr1 = df.format(date) + "\n"+inputStr output.text = inputStr1 //df.format(date) + "\n"+inputStr //val fOut = openFileOutput("testfile.txt", Context.MODE_PRIVATE) val fOut = openFileOutput(inputStr_file, Context.MODE_PRIVATE) fOut.write(inputStr1.toByteArray()) fOut.close() } R.id.btClick1 -> { var temp="" var pathUtf8 = getFilesDir().getAbsolutePath(); //"com.example.hellosample/files/testfile.txt" //temp = File(pathUtf8+"/testfile.txt").readText(Charsets.UTF_8) temp = File(pathUtf8+"/"+ inputStr_file).readText(Charsets.UTF_8) output.text = temp //df.format(date) + "\n" + temp input.setText("") } R.id.btClick2 -> { var pathUtf8 = getFilesDir().getAbsolutePath(); //ファイル名の一覧を取得する val file = File(pathUtf8) val files = file.listFiles() //取得した一覧を表示する var f ="" for (i in files.indices) { val fi= files[i] val files_: String = fi.toString().replace( pathUtf8+ "/" ,"") f = "ファイル" + (i + 1) +" "+ files_ +"\n"+ f } output.text = f val file1 = File(pathUtf8+"/"+ inputStr_delfile) if (file1 != null && file1.length() > 0) { file1.delete() } else { //file.delete() } } //クリアボタンの場合… R.id.btClear -> { //名前入力欄を空文字に設定。 input.setText("") //メッセージ表示欄を空文字に設定。 output.text = "" } } } } }activitymain.xml<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/tv_name"/> <EditText android:id="@+id/etName" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="text" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="保存File名を入力してください"/> <EditText android:id="@+id/fileName" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="text" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="削除するFile名を入力してください"/> <EditText android:id="@+id/delfileName" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="text" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <Button android:id="@+id/btClick" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/bt_click"/> <Button android:id="@+id/btClick1" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/bt_click1" /> <Button android:id="@+id/btClick2" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/bt_click2" /> <Button android:id="@+id/btClear" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/bt_clear"/> </LinearLayout> <ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" tools:context=".MainActivity"> <TextView android:id="@+id/tvOutput" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="25dp" android:text="" android:textSize="25sp" /> </ScrollView> </LinearLayout>strings.xml<?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">Speech to Text</string> <string name="file_name">保存File名を入力してください</string> <string name="del_file_name">削除するFile名を入力してください</string> <string name="tv_name">音声入力してください</string> <string name="bt_click">保存</string> <string name="bt_click1">表示</string> <string name="bt_click2">File削除</string> <string name="bt_clear">クリア</string> </resources>
- 投稿日:2019-12-11T19:41:20+09:00
AndroidでgRPCの双方向通信などにFlowは相性がいいかもしれない話
はじめに
この記事は Android Advent Calendar 2019 11日目の記事です。10日目は @kenz_firespeed さんの Jetpack Composeは速い?遅い? 、12日目は @nichiyoshi さんの 子孫ビューの相対Rect情報を取得してオーバーレイから切り抜く です。
みなさんはKotlinのFlow使ってますか?
趣味でgRPCの双方向ストリーミングを使った簡単なチャットアプリのサンプルを作っていたときに、サーバーとAndroidの値の送受信の部分をFlowを使ってやるといい感じに抽象化できそうだと思ったのでやってみました。作ったもの
ユーザーの概念すらないので、チャットとは…って感じですが、ちゃんと動いてます。
実装
ChatService
インターフェースの定義です。双方向通信を表現するために、Flowを受け取ってFlowを返すメソッドを定義しています。
ChatServiceinterface ChatService { fun flowChatMessage(request: Flow<ChatMessage>): Flow<ChatMessage> }ChatApi
gRPCで通信する部分です。PCのローカルに立てたgRPCのサーバーと通信してます。この時点ではコールバックパターンを使った実装です。
ChatApiclass ChatApi @Inject constructor() { private val channel = ManagedChannelBuilder.forAddress("10.0.2.2", 6565) .usePlaintext() .build() private val chatServiceStub = ChatServiceGrpc.newStub(channel) fun observeChatMessage( onNext: (String) -> Unit, onError: (Throwable) -> Unit, onCompleted: () -> Unit ): StreamObserver<MessageRequest> { return chatServiceStub.execStream(object : StreamObserver<MessageResponse> { override fun onNext(value: MessageResponse?) { value?.message?.let(onNext) } override fun onError(t: Throwable?) { t?.let(onError) } override fun onCompleted() { onCompleted.invoke() } }) } }ChatServiceOnGrpc
先程のChatServiceを継承し、ChatApiを用いて実装します。
ここで、channelFlowを使うことにより、コールバックで受け取った値をFlowに流しています。ChatServiceOnGrpcclass ChatServiceOnGrpc @Inject constructor( private val chatApi: ChatApi ) : ChatService { override fun flowChatMessage( request: Flow<ChatMessage> ): Flow<ChatMessage> = channelFlow<ChatMessage> { val observer = chatApi.observeChatMessage( onNext = { channel.offer(ChatMessage(it)) }, onError = { throw it }, onCompleted = { channel.close() } ) request.collect { val req = MessageRequest.newBuilder() .setMessage(it.value) .build() observer.onNext(req) } awaitClose() } .flowOn(Dispatchers.IO) .buffer() }ChatViewModel
UI側ではLiveDataで扱えると楽ですよね。 ViewModel側で
Flow#asLiveData
とLiveData#asFlow
を使って相互に変換してあげましょう。ChatViewModelclass ChatViewModel( chatService: ChatService ) : ViewModel() { private val myMassage = MutableLiveData<ChatMessage>() val receiveMessage = chatService.flowChatMessage(myMassage.asFlow()) .asLiveData() fun sendMessage(message: String): Job = viewModelScope.launch { myMassage.value = ChatMessage(message) } }あとはActivityやFragment側でobserveしてRecyclerViewに表示するなりすれば完成です。
まとめ
今回、gRPCの双方向ストリーミングをFlowを使ってラップしてみました。これにより、gRPC特有の型への依存をインフラ層に閉じ込め、使う側はFlowで扱いやすくなりました。
gRPCだけでなく、WebSocketなどで双方向通信する場合でもFlowでラップすると使いやすいと思います。今回のサンプルのソースコードはこちらに公開しているので、実際に動かしてみたい場合はどうぞ。
Android側: https://github.com/yt8492/gRPCChat
サーバー側: https://github.com/yt8492/grpc-chat12/12追記 おまけ
WebSocketを使った場合のサンプルも作ってみました。Flowを使ったことにより、ChatServiceを継承したクラスを実装して差し替えるだけで動きます。
ChatServiceOnWebSocket@ExperimentalCoroutinesApi class ChatServiceOnWebSocket @Inject constructor() : ChatService { override fun flowChatMessage( request: Flow<ChatMessage> ): Flow<ChatMessage> = channelFlow<ChatMessage> { withContext(Dispatchers.IO) { val clientSocket = Socket("10.0.2.2", 6789) val serverWriter = clientSocket.getOutputStream().bufferedWriter() val socketReader = clientSocket.getInputStream().bufferedReader() launch { socketReader.lineSequence() .forEach { channel.send(ChatMessage(it)) } } request.collect { withContext(Dispatchers.IO) { serverWriter.write("${it.value}\n") serverWriter.flush() } } awaitClose() } } .flowOn(Dispatchers.IO) .buffer() }
- 投稿日:2019-12-11T17:04:08+09:00
Livedataの罠:双方向databindingの無限ループ
目的
・EditTextの値とSharedPreferenceを連携したい
・EditTextには数字しか入力させたくない
・Livedataを監視し、値を使って処理する想定の実装
EditTextのInputTypeに
number
を指定すれば、数字のみの入力制限は完成です。
さらに、text
に@={livedata}
livedataを指定すると、双方向databindingを完成し、画面で修正した内容をそのままSharedPreferenceに保存するはずです!!ソースコード
SharedPreference
fun getSharedPrefValue(): SharedPreferenceLiveData<String> = mPrefs.stringLiveData(PREF_KEY_PARING_INTEGER, "")* 参考:SharedPreferenceLiveDataの作成方法
Livedata
IntのLivedataで連携したいですが、2wayの場合にはうまくできません。
できる方法があれば、ぜひ!コメント欄で教えてください!var prefValue: MutableLiveData<String> = getSharedPrefValue()Layout XML
<EditText android:id="@+id/paring_pref_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="number" android:text="@={viewModel.prefValue}"/>実行結果
画面が固まって、操作できない!!!
debugして、Livedataの値は無限に変更しているようです。
原因
色々試した結果、
inputType
の問題でした。
1.inputType
にnumber
を指定すると、裏で走っている処理より、EditTextの値が変更されました。
2.その変更をLivedata経由でSharedPreferenceへ保存しました。
3.また、SharedPreferenceの変更をLivedata経由で画面に通知し、EditTextの値を変更しました。
4.EditTextの値が変更されたので、1の処理が呼ばれて、ループになってしまいました。対策
双方向databindingの
text="@={livedata}"
をやめるか、inputTypeの指定inputType="number"
をやめるかの2択しか思いつかない。。。。
- 投稿日:2019-12-11T16:58:03+09:00
AndroidX に migrate する
概要
放置されているアプリを AndroidX 対応したときの手順メモです。
困ることもなくすんなり終わったので大したこと書いてませんがご参考までに。※ Android Studio 3.2 以上が必要です。
コチラに公式手順 があります。
環境
macOS Catalina - 10.15.1
Android Studio - 3.5.2手順
- Android Studio のメニューから Refactor - Migrate to AndroidX
- 確認ダイアログと、現状をバックアップするか聞かれるので一応チェック入れた状態で Migrate
- バックアップのファイル名を聞かれるので入力して Save
- migrate 対象の箇所がズラッと表示されるので対象ファイルを確認して Do Refactor
migrate が完了したら実行してみる
以下のエラーがでた
The given artifact contains a string literal with a package reference 'android.support.v4.content' that cannot be safely rewritten. Libraries using reflection such as annotation processors need to be updated manually to add support for androidx.
- ググると ButterKnife が古いせいのようだったのでバージョンアップ
- implementation 'com.jakewharton:butterknife:9.0.0' - annotationProcessor 'com.jakewharton:butterknife-compiler:9.0.0' + implementation 'com.jakewharton:butterknife:10.0.0' + annotationProcessor 'com.jakewharton:butterknife-compiler:10.0.0'
- もう一度実行してみたら動いたので完了
補足
「Do Refactor」 をすると gradle.properties に以下の2行が追加される
android.useAndroidX=true android.enableJetifier=true作業中なにか割り込みが入るなどして前の状態にしたいときはこの2行も消す必要があります。
参考
- 投稿日:2019-12-11T16:24:36+09:00
INSTALL_REFERRERが廃止されるというお話
ソース
https://android-developers.googleblog.com/2019/11/still-using-installbroadcast-switch-to.html
2020年3月1日から、PlayストアがINSTALL_REFERRERブロードキャストインテントを投げなくなる、ということのようです。
調査どころ
1. 広告SDK、計測系SDKを使っているか?
大抵のSDKは、Play Install Referrer APIへの移行が済んでいるようなので、最新版にアップデートし、各社のマイグレーション手順に従いましょう。(大手のSDKがこの移行が完了したので、停止をアナウンスできるようになったようです)
2. マニフェストファイルの確認
マニフェストファイルに下記の記述があるか確認。
AndroidManifest.xml<intent-filter> <action android:name="com.android.vending.INSTALL_REFERRER"/> </intent-filter>他社製SDK等で使っている以外で、上記記述があるのであれば、Play Install Referrer APIに移行する。
自社製ライブラリなどでもよく調査して下さい。
3. GA/FAを使っている場合
現在、
「これらのアプリがINSTALL_REFERRERを使っているからAPIに変えてね」
と警告するメールが適宜Googleより届いているようです。で、このアプリの抽出が、どうやら、
"INSTALL_REFERRER"だけをgrepかけて検出したアプリを抽出している
っぽいんです。
手持ちのアプリで調べたところ、1にも2にも該当しないのに、対象として挙がっているアプリがあるため、リリースされたapkをAnalyzeしてマージ後のマニフェストファイルを調べたところ、
AndroidManifest.xml<receiver android:enabled="true" android:exported="false" android:name="com.google.android.gms.measurement.AppMeasurementReceiver"/> <receiver android:enabled="true" android:exported="true" android:name="com.google.android.gms.measurement.AppMeasurementInstallReferrerReceiver" android:permission="android.permission.INSTALL_PACKAGES"> <intent-filter> <action android:name="com.android.vending.INSTALL_REFERRER"/> </intent-filter> </receiver>という感じでほとんどのアプリに含まれていることが分かりました。
com.google.android.gms.measurement
は、GA(Google Analytics)のパッケージですが、実はFA(Firebase Analytics)もGAのパッケージに依存しています。では、Firebaseの対応がまだなのか?実は最新版では対応済みだったりする?
ということを調べるため、2019/12/11時点で最新のFirebaseバージョンでreleaseビルドして検証してみました。アプリを取り敢えず新規で作り、Firebaseを導入します。
app/build.gradleimplementation 'com.google.firebase:firebase-analytics:17.2.1'※
com.google.firebase:firebase-core
でも一緒ですreleaseビルドして(署名ファイルは適当に用意)、Android Studioの[Build]-[Analyze APK..]で見てみると・・・
AndroidManifest.xml<receiver android:name="com.google.android.gms.measurement.AppMeasurementInstallReferrerReceiver" android:permission="android.permission.INSTALL_PACKAGES" android:enabled="true" android:exported="true"> <intent-filter> <action android:name="com.android.vending.INSTALL_REFERRER" /> </intent-filter> </receiver>バッチリいます。
これは、Firebaseが対応するのを待つ必要がありそうですね。しばらくは、バージョンアップ情報を注視する必要がありそうです。
(Firebaseの対応が終わってからアナウンスして欲しかった・・・サードパーティー製のSDKの移行は待ったのに、なぜそこを待たない?? もしかして、Firebaseだけ抜け道を用意するつもりorすでに用意している、とかなのかな。対象アプリ検出ロジックのミスの方かも?)
- 投稿日:2019-12-11T14:33:04+09:00
横方向RecyclerViewとSwipeRefreshLayoutが競合してRecyclerViewがスクロールしづらい問題を解決する
本記事はand factory Advent Calendar 2019の11日目の記事になります。昨日はMatsuNaoPenさんのadbコマンドで端末を操作するでした。
問題
僕が開発しているアプリには上記のような画面があります。なんら難しくないRecyclerViewをSwipeRefreshLayoutで囲んであげるだけのやつですね。
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout android:id="@+id/swipe_refresh_layout" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" /> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>こんな感じのxmlになるかと思います。
ある日社内チェックで「なんかスクロールしづらいんですけど、、、」という指摘をされました。
確かに、斜め方向にスクロールすると横にスクロールしたりSwipeRefreshLayoutが反応したりと使いづらいなと感じたので修正してみました。原因はSwipeRefreshLayoutにあり
SwipeRefreshLayoutがスクロールに過敏に反応しすぎてしまうのが原因であることがわかりました。よってSwipeRefreshLayoutをカスタマイズして凡そ縦方向に指をスライドさせた時にだけ反応するように修正します。
修正方法
コードはこちらです
class OnlyVerticalSwipeRefreshLayout(context: Context, attrs: AttributeSet) : SwipeRefreshLayout(context, attrs) { private var touchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop private var prevX: Float = 0.toFloat() private var declined: Boolean = false override fun onInterceptTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { prevX = MotionEvent.obtain(event).x declined = false // New action } MotionEvent.ACTION_MOVE -> { val eventX = event.x val xDiff = abs(eventX - prevX) if (declined || xDiff > touchSlop) { declined = true // Memorize return false } } } return super.onInterceptTouchEvent(event) } }ポイントその1:scaledTouchSlop
scaledTouchSlopとは公式ドキュメントによると
Distance in pixels a touch can wander before we think the user is scrollingと書かれており、
ユーザーがスクロールを開始したとOSが認識する前にスクロールできるピクセル距離ということだと解釈しました。要するにスクロールを開始した瞬間の指の移動距離のことだと思います。
ポイントその2:onInterceptTouchEvent
onInterceptTouchEventでは子Viewで発生したイベントを親Viewが傍受します。更にそのタッチイベントを親Viewイベント奪う場合はtrue、親Viewがイベントを奪わず子Viewにイベントを流す場合はfalseを返却します。今回のケースでは子Viewが横方向RecyclerView、親ViewがSwipeRefreshLayoutになります。
ポイントその3:指の移動距離からSwipeRefreshLayoutがイベントを奪うかどうか判断する
when (event.action) { MotionEvent.ACTION_DOWN -> { prevX = MotionEvent.obtain(event).x declined = false // New action } MotionEvent.ACTION_MOVE -> { val eventX = event.x val xDiff = abs(eventX - prevX) if (declined || xDiff > touchSlop) { declined = true // Memorize return false } } }この部分になります。
ACTION_DOWNで最初のタップ位置を保持します。ACTION_MOVEでx方向の移動距離を計算して、移動距離がtouchSlopを超えた場合はRecyclerViewにイベントを流します。
最後にSwipeRefreshLayoutをOnlyVerticalSwipeRefreshLayoutに変更してあげれば修正完了です。
参考サイト
- 投稿日:2019-12-11T14:14:41+09:00
GitHub Actionsのgradle buildをキャッシュを使って速くしてみる
GitHub Actionsのgradle buildをキャッシュを使って速くしてみる
GitHubを利用しているとActionsの無料枠(ビルド時間)があります。
出来るだけビルド時間を短くして回数を使えるように依存関係のキャッシュの機能を利用してみることにしました。
ymlファイルにgradleのキャッシュを記述する
以下を参考にしてトライ!!
https://github.com/actions/cacheこんな感じ
balnk.ymlname: CI_Cache on: [push] jobs: build: runs-on: ubuntu-latest steps: # Checking out - uses: actions/checkout@v1 # Using caches ←このセクションがキャッシュ - uses: actions/cache@v1 with: path: ~/.gradle/caches key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} restore-keys: | ${{ runner.os }}-gradle- # Setting up JDK - name: set up JDK 1.8 uses: actions/setup-java@v1 with: java-version: 1.8 # The following generates a debug APK - name: Build with Gradle run: ./gradlew assembleDebug実際に速くなったのか検証してみる
キャッシュなしの場合
キャッシュありの場合
ビルド時間は 1m 6s
キャッシュがリストアされているのが分かりますね
まとめ
キャッシュありの方が40秒速い。
この後、何度かキャッシュありとなしで試行してみましたが、キャッシュありの方が早いのでおそらく正しく使えているんだろうという結論。
- 投稿日:2019-12-11T13:55:23+09:00
Webしか触ったことのないアプリ未経験者がFlutterでiOS・Androidのアプリを個人開発でリリースした話
こんにちは、@y_temp4です。普段は主にフリーランスエンジニアとして Web のフロントエンド周りの開発をしており、これまでアプリの開発というものはほぼやったことがありませんでした。
しかし今回、最近流行りの Flutter を使って iOS・Android 両対応のアプリをリリースしてみましたので、そこで得た知見や、開発の流れを共有していこうと思います。
開発したアプリ
今回リリースしたのは「レジスタンス vs スパイ」というアプリです。
以前職場の人とやったボードゲーム「レジスタンス」がとてもおもしろかったので、アプリで作ってみようと思い開発に挑戦してみました。
人狼ライクなボードゲームで、プレイ人数が 5 ~ 10 人と最低でも 5 人必要なのですが、人狼が好きな人ならハマると思うのでぜひプレイしてみてください ?
これまでの開発の経験
アプリ開発の流れを書いていく前に、開発者である私自信のアプリ開発の経験について共有します。
- 普段は Web のフロントエンドの開発がメイン
- アプリの経験はほぼなし(ほんの少しだけ Monaca に触れたことがあるくらい)
- Flutter は少し前にインストールしてチュートリアルだけやったことがあった
自分は完全に Web 系のエンジニアで、アプリの開発はほぼ未経験でした。これまではネイティブに触れた経験はほぼなくて、React Native や Monaca などクロスプラットフォーム系の開発基盤に少し興味を持っていたくらいです。
Flutter に関しては半年くらい前に触ったことがあったのですが、チュートリアルをやったくらいでそのときにはがっつり開発とまではいきませんでした(当時、特に作りたいものもなかったので)。
開発の手順
では実際に、開発をする上での手順をまとめていきます。
1. 画面構成を決める
まずはじめに、アプリの画面構成を考えました。
アプリの遊び方としては、アプリがインストールされた端末をプレイヤーに順番に回していくスタイル(よくある人狼アプリと同じような感じ)を想定していたので、以下を満たす必要がありました。
- 画面が順番に切り替わっていく
- 前の画面には戻れない(前のプレイヤーの情報が見れてしまうので)
そこで、アプリの画面構成としてはスクリーンを複数作成し、それを順番に切り替えていくような感じにしました。
これを、トップページにある「新しいゲームを始める」ボタンから始められるようにします。
また、アプリの途中では戻るボタンは使えませんが、いつでもゲームを破棄してホームに戻れるように、画面の右上に「ホームに戻るボタン」を配置することにしました。
最後に、トップページでは遊び方へのリンクも欲しかったので配置しました。
main.dartimport 'package:flutter/material.dart'; import 'package:resistance/models/app.dart'; import 'package:resistance/screens/home.dart'; import 'package:resistance/screens/select_member_count.dart'; import 'package:resistance/screens/add_user.dart'; import 'package:resistance/screens/check_position_before.dart'; import 'package:resistance/screens/check_position.dart'; import 'package:resistance/screens/discussion_time.dart'; import 'package:resistance/screens/select_mission_member.dart'; import 'package:resistance/screens/vote_of_confidence.dart'; import 'package:resistance/screens/vote_result.dart'; import 'package:resistance/screens/command_mission.dart'; import 'package:resistance/screens/show_mission_result.dart'; import 'package:resistance/screens/command_mission_before.dart'; import 'package:resistance/screens/show_game_result.dart'; import 'package:resistance/screens/how_to_play.dart'; import 'package:scoped_model/scoped_model.dart'; void main() { final app = AppModel(); runApp( ScopedModel<AppModel>( model: app, child: MyApp(), ), ); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: 'レジスタンス vs スパイ', theme: ThemeData( primarySwatch: Colors.blue, textTheme: TextTheme( display4: TextStyle( fontFamily: 'Arial', fontWeight: FontWeight.w800, fontSize: 24, color: Colors.black, ), ), ), initialRoute: '/', routes: { '/': (context) => Home(), '/select_member_count': (context) => SelectMemberCount(), '/add_user': (context) => AddUser(), '/check_position_before': (context) => CheckPositionBefore(), '/check_position': (context) => CheckPosition(), '/discussion_time': (context) => DiscssionTime(), '/select_mission_member': (context) => SelectMissionMember(), '/vote_of_confidence': (context) => VoteOfConfidence(), '/vote_result': (context) => VoteResult(), '/command_mission_before': (context) => CommandMissionBefore(), '/command_mission': (context) => CommandMission(), '/show_mission_result': (context) => ShowMissionResult(), '/show_game_result': (context) => ShowGameResult(), '/how_to_play': (context) => HowToPlay(), }, ); } }2. 状態管理の方法を決める
アプリでは、画面をまたいで状態を保持しておく必要がありました。
Fulutter における状態管理の方法をあまり知らなかったので、とりあえず自分は「グローバルなステートがあればいいや」と思いました。
結果的には scoped_model を使い、1 つだけ app というモデルにアプリすべての状態を保持して管理することにしました。
app.dartimport 'dart:math'; import 'package:scoped_model/scoped_model.dart'; ... class AppModel extends Model { int _memberCount = 0; List _users = []; ... int get memberCount => _memberCount; String get memberCountString => '$_memberCount'; List get users => _users; ... void setMemberCount(int number) { _memberCount = number; } ... }(※長いので省略しています。ちなみに、コードは全体だと 300 行弱くらいです。)
今回のアプリ制作では、結果的にはこれであまり困ることなく開発を進められました。状態がそこまで多くないアプリであれば、これでもなんとかなるかもしれません。
3. 各画面を実装していく
あとは愚直に画面を作成していくだけです。各画面ではそれぞれ地味にロジックを考える必要があって少し悩むこともありましたが、一応最後まで実装することができました。
2. 状態管理の方法を決める
で定義した scoped_model から値やメソッドを呼び出す際はScopedModel.of<AppModel>(context)
を利用します。参考までに、最初の画面のコードを貼っておきます。
select_member_count.dartimport 'package:flutter/material.dart'; import 'package:resistance/models/app.dart'; import 'package:scoped_model/scoped_model.dart'; import 'package:resistance/functions/to_home.dart'; class SelectMemberCount extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('プレイ人数の設定'), actions: [ IconButton(icon: Icon(Icons.home), onPressed: () => toHome(context)), ]), body: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 64), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ MyCustomForm(), ], ))), ); } } class MyCustomForm extends StatefulWidget { @override MyCustomFormState createState() { return MyCustomFormState(); } } class MyCustomFormState extends State<MyCustomForm> { final numberController = new TextEditingController(); String memberCount = '5'; void onClick(BuildContext context) { ScopedModel.of<AppModel>(context).setMemberCount(int.parse(memberCount)); Navigator.pushReplacementNamed(context, '/add_user'); } @override Widget build(BuildContext context) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text('プレイ人数:', style: TextStyle( fontSize: 18, )), DropdownButton<String>( value: memberCount, items: <String>['5', '6', '7', '8', '9', '10'].map((String value) { return DropdownMenuItem<String>( value: value, child: Text( value, style: TextStyle( fontSize: 18, ), ), ); }).toList(), onChanged: (value) { setState(() { memberCount = value; }); }, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: FlatButton( onPressed: () => onClick(context), color: Colors.blue, child: Text( '次へ', style: TextStyle(color: Colors.white), ), ), ), ], )); } }苦労したポイント
開発する中で大変だった点をまとめてみます。
ホームに戻る挙動
画面右上のホームに戻るボタンを実装する際、ホームに戻りますか?というダイアログに対して
- はい → ホームに戻る
- いいえ → 元の画面に戻る
という挙動を実装するのに若干手間取ったのを覚えています。最終的には実装できたので、動作するコードを貼っておきます。
Navigator.of(context).pop()
とNavigator.pushNamedAndRemoveUntil(context, '/', (_) => false)
がポイントですね。to_home.dartimport 'package:flutter/material.dart'; import 'package:resistance/models/app.dart'; import 'package:scoped_model/scoped_model.dart'; void toHome(context) { showDialog( context: context, builder: (BuildContext context) { // return object of type Dialog return AlertDialog( title: Text("確認"), content: Text('ホームに戻ります。現在のゲームのデータはリセットされますが、よろしいですか?'), actions: <Widget>[ // usually buttons at the bottom of the dialog FlatButton( child: Text("いいえ"), onPressed: () { Navigator.of(context).pop(); }, ), FlatButton( child: Text("はい"), onPressed: () { Navigator.pushNamedAndRemoveUntil(context, '/', (_) => false); ScopedModel.of<AppModel>(context).reset(); }, ), ], ); }, ); }Admob が本番環境に反映されない
このアプリは firebase_admob を使って広告を表示していますが、本番環境にて広告が表示されずかなり悩んでいました。
が、結果的に原因はこのリンクにあるように、Admob 側で「お支払い」の設定をしていないことが原因でした・・・。
初めて Admob を使った収益化をする方は、気をつけたほうが良さそうです。
さいごに
全体を通しての感想としては、
- Dart は普段 JS / TS を書いている人であれば親しみやすい
- 実装自体は慣れれば割とスムーズにいけそう
- アプリ独特のリリースまでの流れが面倒だった
といった感じでしょうか。やはりアプリ特有のリリース手順や、開発環境の重さ(エミュレータの起動など)は慣れない部分もあり、Web の開発はその点楽だったんだな・・・と感じました。
とはいえ、これでアプリ開発は終了ではなく、今後も機会があればぜひ Flutter には触れていけたらなと思います?
また、全体通して設計には苦労したので、もし「自分だったらこのように実装するのにな〜」などといったアドバイスがありましたら、コメントにて教えていただけますと幸いです?♂️
もし今回の記事が参考になった方はぜひいいねしていただけますと嬉しいです?
最後まで読んでいただき、ありがとうございました!
- 投稿日:2019-12-11T13:42:52+09:00
Firebase Notificationsのアイコンについて纏めてみた
Android#2 Advent Calendar 2019 14日目の記事です。
久しぶりにAndroid書きます。通知アイコンに悩む人はいまだに多い
Android5.0以降、有名なサービスでもいまだにpushの通知アイコンがただの四角だったり丸い画像だったりするサービスが多いようです。
しかし、下記記事のとおりFirebase messagingの通知アイコンは簡単に設定することができます。
Firebase v9.8からFirebase Notificationsのアイコンが指定できるようになりました
具体的にはManifestファイルにmeda-dataとして通知アイコン画像と色を指定するだけでOKです。
manifest.xml<application ...> <meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/ic_notification" /> <meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color" />公式ドキュメントはこちら。
通知アイコンの見え方
よくある一時停止ボタンをnotification iconとしてmeta-dataに設定します。
使った画像はこちらです。
透過なし画像 透過画像 α値調整した画像 黒円に一時停止マークを赤で塗りつぶし 一時停止マークを透過(α=0.0)で切り抜き 一時停止マークを反透過(α=0.4)の赤 透過なし画像
まずは一時停止のマーク部分を赤く塗り潰した画像の場合です。赤く塗り潰した部分は透過なし(α=1.0)です。
default_notification_color指定なし default_notification_color=紺色 Android 6.0 ![]()
Android 6.0 ![]()
Android 7.0~ ![]()
Android 7.0~ ![]()
Android6.0までの場合
色指定なしの場合、グレーの円の中に白で塗り潰された丸い画像だけが表示されます。
色指定があると、その色の円の中に白で塗り潰された丸い画像だけが表示されます。Android7.0以降
アイコン全体を一色で塗り潰されます。色指定しない場合はグレー、指定した場合は指定色で塗り潰されます。
どれも、元画像の赤も黒も関係なしに塗りつぶされることが分かります。仮に、2色以上の複数色で描かれた画像でも同じです。
透過あり画像
一時停止マークの赤を透過に変更した画像です。
default_notification_color指定なし default_notification_color=紺色 Android 6.0 ![]()
Android 6.0 ![]()
Android 7.0~ ![]()
Android 7.0~ ![]()
Android6.0までの場合
アイコン自体は白になります。
ただし、一時停止マーク部分が透過になるので、そこから下地の円の色(グレー/紺)が見えます。Android7.0以降
こちらも同様に、アイコン自体はグレー/紺色で描画されますが、透過部分から背景の白色が見えるようになります。
α値調整をした画像
先ほど透過にした一時停止マーク部分のα値を調整し(α=0.4)、赤の反透過にしたものです。
default_notification_color指定なし default_notification_color=紺色 Android 6.0 ![]()
Android 6.0 ![]()
Android 7.0~ ![]()
Android 7.0~ ![]()
少しわかりづらいかもしれませんが、一時停止マークの部分が、左側は薄いグレー、右側は薄い紺色で描画されています。
要は画像のアルファチャンネルのみが使用されるため、指定した色にαがかかった形で描画されます。そのため、元の画像が何色で作られていても関係ありません。Darkテーマの場合
基本、Darkテーマの場合、色指定がない場合はグレーではなく白になるだけで変わりはありません。
default_notification_color指定なし default_notification_color=紺色 ※α値がわかりづらくてすみません…
まとめ
- 通知アイコンは画像のアルファチャンネルのみ使用される
- 通知アイコンは1色でレンダリングされる
- 表現力の高い通知アイコンを作りたい場合は、アルファチャンネルを使ってα値の濃淡で表現する
- 投稿日:2019-12-11T12:15:02+09:00
DataBinding/ViewBindingはNonNullかlateinit管理なので意図しないViewへの参照をどうやって防ぐのが良いか
Fragmentを使った実装をしているときにViewを参照しようとして画面がクラッシュするケースがあります。
例えば
- 親のActivityからFragmentを参照したが、Fragmentのライフサイクルは
Destroyed
だった- なぜか意図しないタイミングでコールされる
OnScrollChangedListener
onActivityCreated
やonResume
のタイミングでセットしたRunnable
処理などです。
なぜかonDestroyView
やonPause
でリスナーを切っていてもリリース後に数件とかちらほら上がってくることがあります。画面が破棄されたかどうかのチェックがしづらくなった
これは
LiveData
やCoroutine
がKTXなどの拡張モジュールも充実してきていて、ライフサイクルに紐づいた処理ができるのでプロダクトで使えていればメモリーリークや意図しないViewの参照によるクラッシュが起こりづらくなってきたためだと思われます。とはいえ、歴史のあるプロダクトでは古いコードが乱立している中、全てのサービスでトレンドに沿った実装ができるわけではないと思います。かといって、
DataBinding
がnull
かどうかのチェックは現実的ではないし、lateinit
も画面破棄後のチェックはしづらいです。
ButterKnifeが全盛だった頃は、Fragment内でUnbinder
クラスをNullable
で定義していたので、画面が破棄されたかどうかはUnbinderがnullかどうか
で判別していたりしました。isViewDestroyedフラグを作る
各リスナーのコールバックの中で下記のような早期リターンを付けてあげるようにしました。
Fragmentクラスの中に良さげな変数を見つけたのでそれを使ってみます。FragmentExt.ktfun Fragment.isViewDestroyed(): Boolean { return viewLifecycleOwnerLiveData.value == null }
viewLifecycleOwnerLiveData
はLifecycleOwner
をLiveDataで管理していて、onCreateViewからonDestroyViewのスコープでvalueを取得できるみたいです。逆にonDestroyView以降の参照であればvalueはnull
になります。Fragment.java/** * Get a {@link LifecycleOwner} that represents the {@link #getView() Fragment's View} * lifecycle. In most cases, this mirrors the lifecycle of the Fragment itself, but in cases * of {@link FragmentTransaction#detach(Fragment) detached} Fragments, the lifecycle of the * Fragment can be considerably longer than the lifecycle of the View itself. * <p> * Namely, the lifecycle of the Fragment's View is: * <ol> * <li>{@link Lifecycle.Event#ON_CREATE created} after {@link #onViewStateRestored(Bundle)}</li> * <li>{@link Lifecycle.Event#ON_START started} after {@link #onStart()}</li> * <li>{@link Lifecycle.Event#ON_RESUME resumed} after {@link #onResume()}</li> * <li>{@link Lifecycle.Event#ON_PAUSE paused} before {@link #onPause()}</li> * <li>{@link Lifecycle.Event#ON_STOP stopped} before {@link #onStop()}</li> * <li>{@link Lifecycle.Event#ON_DESTROY destroyed} before {@link #onDestroyView()}</li> * </ol> * * The first method where it is safe to access the view lifecycle is * {@link #onCreateView(LayoutInflater, ViewGroup, Bundle)} under the condition that you must * return a non-null view (an IllegalStateException will be thrown if you access the view * lifecycle but don't return a non-null view). * <p>The view lifecycle remains valid through the call to {@link #onDestroyView()}, after which * {@link #getView()} will return null, the view lifecycle will be destroyed, and this method * will throw an IllegalStateException. Consider using * {@link #getViewLifecycleOwnerLiveData()} or {@link FragmentTransaction#runOnCommit(Runnable)} * to receive a callback for when the Fragment's view lifecycle is available. * <p> * This should only be called on the main thread. * <p> * Overriding this method is no longer supported and this method will be made * <code>final</code> in a future version of Fragment. * * @return A {@link LifecycleOwner} that represents the {@link #getView() Fragment's View} * lifecycle. * @throws IllegalStateException if the {@link #getView() Fragment's View is null}. */ @MainThread @NonNull public LifecycleOwner getViewLifecycleOwner() { if (mViewLifecycleOwner == null) { throw new IllegalStateException("Can't access the Fragment View's LifecycleOwner when " + "getView() is null i.e., before onCreateView() or after onDestroyView()"); } return mViewLifecycleOwner; } /** * Retrieve a {@link LiveData} which allows you to observe the * {@link #getViewLifecycleOwner() lifecycle of the Fragment's View}. * <p> * This will be set to the new {@link LifecycleOwner} after {@link #onCreateView} returns a * non-null View and will set to null after {@link #onDestroyView()}. * <p> * Overriding this method is no longer supported and this method will be made * <code>final</code> in a future version of Fragment. * * @return A LiveData that changes in sync with {@link #getViewLifecycleOwner()}. */ @NonNull public LiveData<LifecycleOwner> getViewLifecycleOwnerLiveData() { return mViewLifecycleOwnerLiveData; }
getViewLifecycleOwner
はonDestroyViewが呼ばれた後に参照するとエラーになるのでviewLifecycleOwnerLiveData
を見る方が良さそうです。Overriding this method is no longer supported and this method will be made <code>final</code> in a future version of Fragment.この一文が気になるけど
Link
- Suspending over Views
- 投稿日:2019-12-11T11:18:50+09:00
Unityを使って手軽にスマホアプリからtoioを操作する方法(ライブラリ付き)
はじめに
Hiroshi Takagiと申します。普段は組み込みエンジニアをやっています。
toioで公開されている、技術仕様書とjavascriptライブラリを参考にしながら、Unityを使ってスマホアプリで動作するtoioの開発環境を作りました。
完成した。 pic.twitter.com/SNwwaIIcmn
— Hiroshi Takagi (@TkgHrsh) December 13, 2019技術仕様ver2.0.0で公開されているものはほぼすべて操作可能なスクリプトもご用意したので、興味ある方はぜひ使ってみてください。
ただ、できるだけ素早く簡単にできることを目標にしたので、Bluetooth Low Energy(BLE)の有料アセットが必要になります。
Unityはつい最近始めたのですが、学習にはこの本がおすすめです。
Unityの教科書 Unity2019完全対応版それではやっていきましょう。
使ったもの
- Unity (2018.4.14f1)
- toio コア キューブ 1台
- Mac 1台 [Mac mini(2018) OS Version10.14.6]
- iPhone 1台 [iPhone 7]
- Xcode(11.3)
Windows,Androidではまだ試していませんので、もしやってみたかたいましたら、ご連絡いただけると嬉しいです。
アセットのインポート
まずは以下のアセットを新規2Dプロジェクトにインポートします。
https://assetstore.unity.com/packages/tools/network/bluetooth-le-for-ios-tvos-and-android-26661アセットのImport方法が分からない方はこの記事を参考にするとよいかと思います。
キューブを操作するスクリプトの配置
以下をコピーして、プロジェクト内のC#スクリプトとして使用してください。
コード全文
CubeController.csusing System; using UnityEngine; public class CubeLightParams { public uint red; public uint green; public uint blue; public UInt16 durationMs; public CubeLightParams(uint red, uint green, uint blue, UInt16 durationMs) { this.red = red; this.green = green; this.blue = blue; this.durationMs = durationMs; } } public class CubeSoundParams { public uint noteNum; public UInt16 durationMs; public CubeSoundParams(uint noteNum, UInt16 durationMs) { this.noteNum = noteNum; this.durationMs = durationMs; } } public class CubeController : MonoBehaviour { public enum States { None, Scan, ScanRSSI, Connect, Disconnect, Connecting, } private const string DeviceName = "toio Core Cube"; private const string ServiceUUID = "10B20100-5B3B-4571-9508-CF3EFCD7BBAE"; private const string IdCharacteristic = "10B20101-5B3B-4571-9508-CF3EFCD7BBAE"; private const string SensorCharacteristic = "10B20106-5B3B-4571-9508-CF3EFCD7BBAE"; private const string ButtonCharacteristic = "10B20107-5B3B-4571-9508-CF3EFCD7BBAE"; private const string BatteryCharacteristic = "10B20108-5B3B-4571-9508-CF3EFCD7BBAE"; private const string MotorCharacteristic = "10B20102-5B3B-4571-9508-CF3EFCD7BBAE"; private const string LightCharacteristic = "10B20103-5B3B-4571-9508-CF3EFCD7BBAE"; private const string SoundCharacteristic = "10B20104-5B3B-4571-9508-CF3EFCD7BBAE"; private const string ConfigrationCharacteristic = "10B201FF-5B3B-4571-9508-CF3EFCD7BBAE"; private string[] _characteristics = { IdCharacteristic, SensorCharacteristic, ButtonCharacteristic, BatteryCharacteristic, MotorCharacteristic, LightCharacteristic, SoundCharacteristic, ConfigrationCharacteristic }; private float _timeout = 0f; private States _state = States.None; private string _deviceAddress; private int _foundCharCount = 0; private bool _rssiOnly = false; private int _rssi = 0; void Reset() { _timeout = 0f; _state = States.None; _deviceAddress = null; _foundCharCount = 0; _rssi = 0; } void SetState(States newState, float timeout) { _state = newState; _timeout = timeout; } void StartProcess() { Reset(); BluetoothLEHardwareInterface.Initialize(true, false, () => { SetState(States.Scan, 0.1f); }, (error) => { BluetoothLEHardwareInterface.Log("Error during initialize: " + error); }); } // Use this for initialization void Start() { StartProcess(); } // Update is called once per frame void Update() { if (_timeout > 0f) { _timeout -= Time.deltaTime; if (_timeout <= 0f) { _timeout = 0f; switch (_state) { case States.None: break; case States.Scan: BluetoothLEHardwareInterface.ScanForPeripheralsWithServices(null, (address, name) => { // if your device does not advertise the rssi and manufacturer specific data // then you must use this callback because the next callback only gets called // if you have manufacturer specific data if (!_rssiOnly) { if (name.Contains(DeviceName)) { BluetoothLEHardwareInterface.StopScan(); // found a device with the name we want // this example does not deal with finding more than one _deviceAddress = address; SetState(States.Connect, 0.5f); } } }, (address, name, rssi, bytes) => { // use this one if the device responses with manufacturer specific data and the rssi if (name.Contains(DeviceName)) { if (_rssiOnly) { _rssi = rssi; } else { BluetoothLEHardwareInterface.StopScan(); // found a device with the name we want // this example does not deal with finding more than one _deviceAddress = address; SetState(States.Connect, 0.5f); } } }, _rssiOnly); // this last setting allows RFduino to send RSSI without having manufacturer data if (_rssiOnly) SetState(States.ScanRSSI, 0.5f); break; case States.ScanRSSI: break; case States.Connect: // set these flags _foundCharCount = 0; // note that the first parameter is the address, not the name. I have not fixed this because // of backwards compatiblity. BluetoothLEHardwareInterface.ConnectToPeripheral(_deviceAddress, null, null, (address, serviceUUID, characteristicUUID) => { if (IsEqual(serviceUUID, ServiceUUID)) { for (int i = 0; i < this._characteristics.Length; i++) { if (IsEqual(characteristicUUID, this._characteristics[i])) { this._foundCharCount++; } } // if we have found all characteristics that we are waiting for // set the state. make sure there is enough timeout that if the // device is still enumerating other characteristics it finishes // before we try to subscribe if (this._foundCharCount == this._characteristics.Length) { SetState(States.Connecting, 0); batterySubscribe(); motionSensorSubscribe(); buttonSubscribe(); idInformationSubscribe(); } } }); break; } } } } bool IsEqual(string uuid1, string uuid2) { return (uuid1.ToUpper().CompareTo(uuid2.ToUpper()) == 0); } // // Motor // public void Move(int left, int right, uint durationMs) { if (_state != States.Connecting) { Debug.Log("Cube is not ready"); return; } byte leftDir = (byte)((left >= 0) ? 01 : 02); byte rightDir = (byte)((right >= 0) ? 01 : 02); byte leftVal = (byte)Math.Min(Math.Abs(left), 0xff); byte rightVal = (byte)Math.Min(Math.Abs(right), 0xff); byte dur = (byte)Math.Min(durationMs / 10, 0xff); byte[] data = new byte[] { 02, 01, leftDir, leftVal, 02, rightDir, rightVal, dur }; BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, MotorCharacteristic, data, data.Length, false, (characteristicUUID) => { BluetoothLEHardwareInterface.Log("Write Succeeded"); }); } public void MoveStop() { if (_state != States.Connecting) { Debug.Log("Cube is not ready"); return; } byte[] data = new byte[] { 01, 01, 01, 00, 02, 01, 00 }; BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, MotorCharacteristic, data, data.Length, false, (characteristicUUID) => { BluetoothLEHardwareInterface.Log("Write Succeeded"); }); } // // Light // public void LightUp(CubeLightParams[] arr, uint repeat) { if (_state != States.Connecting) { Debug.Log("Cube is not ready"); return; } if (arr.Length >= 30) { Debug.Log("too much array Length"); return; } byte[] data = new byte[3 + 6 * arr.Length]; int len = 0; data[len++] = 04; data[len++] = (byte)repeat; data[len++] = (byte)arr.Length; for (int i = 0; i < arr.Length; i++) { data[len++] = (byte)Math.Min(arr[i].durationMs / 10, 0xff); data[len++] = 01; data[len++] = 01; data[len++] = (byte)arr[i].red; data[len++] = (byte)arr[i].green; data[len++] = (byte)arr[i].blue; } BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, LightCharacteristic, data, data.Length, true, (characteristicUUID) => { BluetoothLEHardwareInterface.Log("Write Succeeded"); }); } public void LightOff() { if (_state != States.Connecting) { Debug.Log("Cube is not ready"); return; } byte[] data = new byte[] { 01 }; BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, LightCharacteristic, data, data.Length, true, (characteristicUUID) => { BluetoothLEHardwareInterface.Log("Write Succeeded"); }); } // // Sound // public void Sound(CubeSoundParams[] arr, uint repeat) { if (_state != States.Connecting) { Debug.Log("Cube is not ready"); return; } if (arr.Length >= 60) { Debug.Log("too much array Length"); return; } byte[] data = new byte[3 + 3 * arr.Length]; int len = 0; data[len++] = 03; data[len++] = (byte)repeat; data[len++] = (byte)arr.Length; for (int i = 0; i < arr.Length; i++) { data[len++] = (byte)Math.Min(arr[i].durationMs / 10, 0xff); data[len++] = (byte)arr[i].noteNum; data[len++] = 0xff; } BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, SoundCharacteristic, data, data.Length, true, (characteristicUUID) => { BluetoothLEHardwareInterface.Log("Write Succeeded"); }); } public void SoundPreset(uint id) { if (_state != States.Connecting) { Debug.Log("Cube is not ready"); return; } byte[] data = new byte[] { 02, (byte)id, 0xff }; BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, SoundCharacteristic, data, data.Length, true, (characteristicUUID) => { BluetoothLEHardwareInterface.Log("Write Succeeded"); }); } public void SoundOff() { if (_state != States.Connecting) { Debug.Log("Cube is not ready"); return; } byte[] data = new byte[] { 01 }; BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, SoundCharacteristic, data, data.Length, true, (characteristicUUID) => { BluetoothLEHardwareInterface.Log("Write Succeeded"); }); } // // Battery // private Action<uint> batteryCb = null; private void batterySubscribe() { BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, BatteryCharacteristic, null, (address, characteristic, bytes) => { if (this.batteryCb != null) { this.batteryCb(bytes[0]); } }); } public void GetBattery(Action<uint> result) { this.batteryCb = result; } // // Motion Sensor // private bool lastCollisiton = false; private Action<bool, bool> motionSensorCb = null; private void motionSensorSubscribe() { BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, SensorCharacteristic, null, (address, characteristic, bytes) => { Debug.Log("motion sensro changed"); if (this.motionSensorCb != null) { if (bytes[0] == 01) { bool flat = (bytes[1] == 01); bool collisiton = (bytes[2] == 01); this.motionSensorCb(flat, collisiton); this.lastCollisiton = collisiton; } } }); } public void GetMotionSensor(Action<bool, bool> result) { this.motionSensorCb = result; } // // Button // private Action<bool> buttonCb = null; private void buttonSubscribe() { BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, ButtonCharacteristic, null, (address, characteristic, bytes) => { if (this.buttonCb != null) { if (bytes[0] == 01) { this.buttonCb(bytes[1] == 0x80); } } }); } public void GetButton(Action<bool> result) { this.buttonCb = result; } // // ID Information // private Action<UInt16, UInt16, UInt32, UInt16> idInformationCb = null; private void idInformationSubscribe() { UInt16 positionX = 0xffff; UInt16 positionY = 0xffff; UInt16 angle = 0xffff; UInt32 standardID = 0xffffffff; BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, IdCharacteristic, null, (address, characteristic, bytes) => { if (this.idInformationCb != null) { switch (bytes[0]) { case 01: positionX = (UInt16)(bytes[1] | (bytes[2] << 8)); positionY = (UInt16)(bytes[3] | (bytes[4] << 8)); standardID = 0xffffffff; angle = (UInt16)(bytes[5] | (bytes[6] << 8)); this.idInformationCb(positionX, positionY, standardID, angle); break; case 02: positionX = 0xffff; positionY = 0xffff; standardID = (UInt32)(bytes[1] | (bytes[2] << 8) | (bytes[3] << 16) | (bytes[4] << 24)); angle = (UInt16)(bytes[5] | (bytes[6] << 8)); this.idInformationCb(positionX, positionY, standardID, angle); break; case 03: case 04: positionX = 0xffff; positionY = 0xffff; standardID = 0xffffffff; angle = 0xffff; this.idInformationCb(positionX, positionY, standardID, angle); break; } } }); } public void GetIdInformation(Action<UInt16, UInt16, UInt32, UInt16> result) { this.idInformationCb = result; } }このスクリプトは、自動的にキューブを探してペアリングして、完了すると各機能の操作および状態の通知が実行されるようになっています。
サンプルアプリケーションの作成
以下をコピーして、プロジェクト内のC#スクリプトとして使用してください。
コード全文
Director.csusing System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class Director : MonoBehaviour { GameObject Battery; GameObject Flat; GameObject Collision; GameObject CubeController; GameObject Button; GameObject PositionID; GameObject StandardID; GameObject Angle; GameObject Tap; bool enMove = false; // Start is called before the first frame update void Start() { this.Battery = GameObject.Find("Battery"); this.Flat = GameObject.Find("Flat"); this.Collision = GameObject.Find("Collision"); this.CubeController = GameObject.Find("CubeController"); this.Button = GameObject.Find("Button"); this.PositionID = GameObject.Find("PositionID"); this.StandardID = GameObject.Find("StandardID"); this.Angle = GameObject.Find("Angle"); this.Tap = GameObject.Find("Tap"); notifyStatus(); } // Update is called once per frame void Update() { if (Input.GetMouseButtonDown(0)) { if (enMove) { stopMove(); } else { startMove(); } enMove = !enMove; } } private void startMove() { CubeSoundParams[] sound = new CubeSoundParams[4]; sound[0] = new CubeSoundParams(60, 200); sound[1] = new CubeSoundParams(72, 200); sound[2] = new CubeSoundParams(84, 200); sound[3] = new CubeSoundParams(128, 1000); CubeLightParams[] light = new CubeLightParams[4]; light[0] = new CubeLightParams(255, 255, 0, 400); light[1] = new CubeLightParams(0, 255, 255, 400); light[2] = new CubeLightParams(255, 0, 255, 400); light[3] = new CubeLightParams(255, 255, 255, 400); this.CubeController.GetComponent<CubeController>().Sound(sound, 0); this.CubeController.GetComponent<CubeController>().LightUp(light, 0); this.CubeController.GetComponent<CubeController>().Move(20, 20, 0); this.Tap.GetComponent<Text>().text = "Tap to stop moving"; } private void stopMove() { this.CubeController.GetComponent<CubeController>().SoundOff(); this.CubeController.GetComponent<CubeController>().LightOff(); this.CubeController.GetComponent<CubeController>().MoveStop(); this.Tap.GetComponent<Text>().text = "Tap to start moving"; } private void notifyStatus() { this.CubeController.GetComponent<CubeController>().GetBattery((result) => { this.Battery.GetComponent<Text>().text = "Battery残量 : " + result.ToString("D3") + "%"; }); this.CubeController.GetComponent<CubeController>().GetMotionSensor((isFlat, isCollision) => { if (isFlat) { this.Flat.GetComponent<Text>().text = "水平検出 : 水平"; } else { this.Flat.GetComponent<Text>().text = "水平検出 : 水平でない"; } if (isCollision) { this.Collision.GetComponent<Text>().text = "衝突検出 : 衝突あり"; } else { this.Collision.GetComponent<Text>().text = "衝突検出 : 衝突なし"; } }); this.CubeController.GetComponent<CubeController>().GetButton((result) => { if (result) { this.Button.GetComponent<Text>().text = "ボタン : ON"; } else { this.Button.GetComponent<Text>().text = "ボタン : OFF"; } }); this.CubeController.GetComponent<CubeController>().GetIdInformation((positionX, positionY, standardID, angle) => { if (positionX != 0xffff || positionY != 0xffff) { this.PositionID.GetComponent<Text>().text = "X座標 : " + positionX.ToString("D3") + " / Y座標 : " + positionY.ToString("D3"); } else { this.PositionID.GetComponent<Text>().text = "Position ID情報なし"; } if (standardID != 0xffffffff) { this.StandardID.GetComponent<Text>().text = "Standard Id : " + standardID.ToString("D7"); } else { this.StandardID.GetComponent<Text>().text = "Standard ID情報なし"; } if (angle != 0xffff) { this.Angle.GetComponent<Text>().text = "角度 : " + angle.ToString("D3"); } else { this.Angle.GetComponent<Text>().text = "角度情報なし"; } }); } }今回のサンプルアプリケーションの構成です。
下図のように、空のObjectを二つ(CubeController, Director)とTextオブジェクト(Battery, Flat, Collision, Button, PositionID, StandardID, Angle, Tap)を8個を登録してください。
名前を間違えるとうまく動きませんのでご注意ください。Textの配置とサイズは適当にお願いします。最後に、CubeControllerにCubeController.csを、DirectorにDirector.csをアタッチすれば準備完了です。
ビルドしてスマホに移す
この記事を参考にするとよいと思います。
iOSの話になってしまいますが、一点注意点です。
XCODEから実行する際に、下図のように、Infoに[Privacy - Bluetooth Always Usage Description]を追加する必要があります。これがないとアプリが起動できなくなります。
さいごに
うまく動きましたでしょうか?
あとはサンプルアプリケーションを参考に、自分のオリジナルのtoioアプリが作成できると思いますので、ぜひ使ってみてください。最新のtoioの技術仕様書を見ると、ver2.1.0に更新されているので、またアップデートしてご紹介したいと思います。
それではよいクリスマスをお過ごしください。
- 投稿日:2019-12-11T11:06:59+09:00
React NativeとFlutterのレンダリングアーキテクチャ
はじめに
React NativeとFlutterのレンダリングアーキテクチャについて紹介します。
React NativeとFlutterは、ともにモバイルアプリのクロスプラットフォーム開発フレームワークですが、JS/Dartコードとネイティブコード間の相互通信の方法、コアエンジンの違いなど、様々な違いが存在します。
本記事では、それらの内容について紹介したいと思います。
React Native
React Nativeでは、これまでのJavaScriptクロスプラットフォームフレームワークとは異なり、プラットフォームごとにネイティブウィジェットを呼び出しレンダリングを行います。
以下では、レンダリングが行われるまでの仕組みと、JavaScriptとネイティブコード間の通信について紹介します。
Thread
React Native ではすべての処理が以下のスレッドで実行されます。
- Main Thread
- Shadow Queue (Shadow Node Thread)
- Native Modules
- JS Thread
Main Thread
はUIのレンダリングを行うスレッドです。TouchやPressなどのインタラクションイベントの受け取りも行い、ブリッジを介してJS Thread
へ通知します。
Shadow Queue
はUIのレンダリングに必要なプロパティを受け取り、UIの位置を決定するための演算処理を行います。レンダリングをする準備が整うとMain Thread
に処理を引き渡します。
Native Modules
はネイティブAPIを使用した処理を行い、各Native Module
は独自のスレッドで動作します。iOSではパラレルGCDキューを使用し、Androidではスレッドプールを共有します。
JS Thread
はすべてのJavaScriptアプリケーションコードが実行されるスレッドです。JavaScriptイベントループに基づいているため、UIスレッドよりも遅く、アプリケーションで複雑な計算を行って多くのUIを変更すると、パフォーマンスが低下する可能性があります。ブリッジ
JavaScriptとネイティブコード間のすべての通信およびメッセージはブリッジを介します。
ブリッジを介した情報は、
Async Serialized Batched
により下記のJSON形式にシリアル化され、MessageQueue
で処理されます。example// type: 0=N->JS, 1=JS->N type BridgeData = { type: number, module: ?string, method: string | number, args: any }ネイティブモジュール
ネイティブモジュールは、ネイティブAPIへのアクセスを提供します。
最初に、ネイティブモジュールまたはUIコンポーネントを構築する必要があるかどうかを選択する必要があります。ネイティブモジュールは、メソッドと定数をエクスポートするだけでUIをレンダリングは行いません。
UIコンポーネント
UIコンポーネントを構築するには、ViewManager を使用します。ViewManager は View を生成するファクトリで、ViewManager 自身のインスタンスがブリッジごとに作成されます。
ViewManagerとブリッジは以下の図の通りに働き、ネイティブビューをレンダリングします。
- ブリッジは、全てのネイティブモジュールの情報を保持します
- ネイティブコンポーネントを要求します (
requireNativeComponent
)- ViewManagerは、ブリッジのビューインスタンスへの参照を格納するビューを作成します
- ビューの参照を送信します
- 他のReact Componentと同様に
render
を呼び出し、最終的にネイティブビューをレンダリングしますFlutter
一方、フラッターではネイティブウィジェットのレンダリングを行いません。Dartフレームワークで管理されたウィジェットを呼び出し、レンダリングエンジンに依存して2Dウィジェット要素をペイントします。
主にC++で記述されたFlutterのレンダリングエンジンは、GoogleのSkia Graphics Libraryを使用して低レベルのレンダリングサポートを提供します。
参照: The-Engine-architecture - GitHubThread
Flutterエンジンは、独自のスレッドを作成または管理せず、
Embedder
(各プラットフォーム)で作成・管理する必要があります。また、Embedder
はFlutterエンジンのスレッドで動作するTask Runnerを提供します。The Flutter engine does not create or manage its own threads. Instead, it is the responsibility of the embedder to create and manage threads (and their message loops) for the Flutter engine. The embedder gives the Flutter engine task runners for the threads it manages. In addition to the threads managed by the embedder for the engine, the Dart VM also has its own thread pool. Neither the Flutter engine or the embedder have any access to the threads in this pool.
Task Runner
主なタスクランナーは次のとおりです。
Platform Task Runner
UI Task Runner
GPU Task Runner
IO Task Runner
Platform Task Runner
は、Embedder
がメインスレッドと見なすスレッドのタスクランナーです。
例えば、Androidではメインスレッド、iOSではFoundationによって参照されるメインスレッドです。
UI Task Runner
は、エンジンがRoot IsolateのすべてのDartコードを実行する場所です。
GPU Task Runner
は、デバイス上のGPUにアクセスする必要があるタスクを実行します。OpenGL
Vulkan
などのSkia用にセットアップされたバックエンドソフトウェアを使用してレンダリングを行うことができます。
IO Task Runner
は、主にアセットから圧縮画像を読み取り、画像データを処理し、共有のContextを通じてGPU Task Runner
に処理を引き渡すことができます。つまり、ディスクIOに関連するトランザクションが処理されます。レンダリング結果の違い
React NativeとFlutterでレンダリングアーキテクチャが大きく異なる点として、React Nativeはネイティブコードが提供するモジュールをレンダリングし、FlutterはDartフレームワークに組み込まれたウィジェットを、Skia Graphics Libraryがレンダリングする点です。
これは、ウィジェットが最終的にレンダリングされる結果に違いを及ぼします。
上記の例として、マテリアルデザインが採用された Android 5.0 (APILevel 21) 以降と未満ではUIが大きく変わります。
実際にReact Nativeで作成したアプリでは、特別な処理やライブラリを入れない限り、ネイティブコードをレンダリングするため、上記の仕様が踏襲されるはずです。
一方、Flutterのレンダリングエンジンは、OSバージョンやAPILevelに関係なく、Dartフレームワークに組み込まれたマテリアルデザインウィジェット(Android)/クパチーノウィジェット(iOS)をレンダリングするため、バージョンの差分を吸収して同じ結果が出力されるはずです。
おわりに
これらの内容は、アプリのパフォーマンスチューニングを行う場合や、ネイティブAPIを使用する必要がある場合にとても役立つ情報となります。
また、コアレンダリングエンジンの違いは、技術選定を行う上で重要なポイントとなるのではないでしょうか。
この記事が少しでもモバイルアプリ開発者の参考になれば幸いです。参考URL
- 投稿日:2019-12-11T10:59:50+09:00
Notification Channelの内容の更新でちょっとハマった話
Notification Channelでアプリの前バージョンでsetSoundしていて、
それをしないように更新しても、音が鳴り続けてしまうので、なんでやって調べたところ、
どうやらNotificaion Channelで一度設定した項目はプログラムからは変更できないらしい。理由は以下、
After you create a notification channel, you cannot change the notification channel's visual and auditory behaviors programmatically—only the user can change the channel behaviors from the system settings.
要は、ユーザからもチャンネルの設定の変更ができるため、一度作成したチャンネルの動作はプログラムからは変更できないということ。
まあ確かに言う通りで、ユーザの好きな設定にしているのにアプリのアップデートで勝手に変わってはたまったもんじゃない。それでもどうしても変えたい場合は、一回古いチャンネルを削除して、新しくチャンネルを作り直す必要がある。
Notification.ktval notificationManager = NotificationManagerCompat.from(context) notificationManager.deleteNotificationChannel("古いnotificationのchannelId")注意するのは、deleteするのは古いnotificationのchannelIdだということ。
そして新しく作るNotification Channelはdeleteするのとは違うchannelIdに設定すること。もちろん実行すると、今までユーザが設定していたチャンネルごと削除されてしまうので、
ユーザが設定していた項目はなかったことになる。
よってオススメはしません。Notification Channelの設定はよく考えて設定しましょう(戒め)
- 投稿日:2019-12-11T07:46:59+09:00
[Android]画像をとりあえず Drawable フォルダに放り込むのをやめる
はじめに
Android ではデバイスの表示画面に応じた、ビットマップ画像を格納する仕組みが用意されています。
それがぞくに言う Drawable フォルダというやつで、DPI に応じて画像を格納できるフォルダが用意されています。
密度修飾子 Drawable フォルダ DPI値 ピクセル比 スケーリング比 説明 LDPI drawable-ldpi 〜120 36x36 0.75 低密度(ldpi)の画面(〜120dpi)に適応するリソース MDPI drawable 〜160 48x48 1.00 MDPI drawable-mdpi 〜160 48x48 1.00 中密度(mdpi)の画面(〜160dpi)に適応するリソース HDPI drawable-hdpi 〜240 72x72 1.50 高密度(hdpi)の画面(〜240dpi)に適応するリソース XHDPI drawable-xhdpi 〜320 96x96 2.00 超高密度(xhdpi)の画面(〜320dpi)に適応するリソース XXHDPI drawable-xxhdpi 〜480 144x144 3.00 超超高密度(xxhdpi)の画面(〜480dpi)に適応するリソース XXXHDPI drawable-xxxhdpi 〜640 192x192 4.00 超超超高密度(xxxhdpi)の画面(〜640dpi)に適応するリソース NODPI drawable-nodpi - - - すべての密度に適用するリソース 今までなるほど〜という感じで、個人開発では適当な Drawable に画像を放り込んでいました。
そのような感じで適当な運用をしていたらレイアウトが崩れなどの問題が起きたので、
ここらで Drawable の仕組みについて調べてまとめてみたいと思います。画面の DPI に応じたリソースに切り替えてくれる
Drawable フォルダは画面の DPI ごとにフォルダが区切られており、
Drawable フォルダに各画面のビットマップを格納しておけば、
画面の DPI に応じたリソースに自動で切り替えてくれるようになっています。準備
表示するリソースを準備する
次の手順で 各 Drawable フォルダに画像を格納して、DPI の違う画面ごとにリソースを準備します。
今回は表示が切り替わっているが変わりやすいように、画像にピクセル比を載せたものを用意します。
res
フォルダをエクスプローラなどで開くDrawable
フォルダを作成し、dot.png
という名称で格納する。
Drawable フォルダ ピクセル 画像 drawable-ldpi 36x36 drawable-mdpi 48x48 drawable-hdpi 72x72 drawable-xhdpi 96x96 drawable-xxhdpi 144x144 drawable-xxxhdpi 192x192 画像を表示するアプリを作成する
次の手順で Drawable フォルダに格納した画像である、
dot.png
ファイルを表示するアプリケーションを作成します。
activity_main.xml
を開く- 画面中央に
ImageView
を配置し、src
に@drawable/dot
を指定する。activity_main.xml<?xml version="1.0" encoding="utf-8"?> <FrameLayout 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=".MainActivity"> <ImageView android:layout_width="200dp" android:layout_height="200dp" android:layout_gravity="center" android:src="@drawable/dot"/> </FrameLayout>実践
準備が整ったので、DPI が異なるデバイスでアプリを起動して動作を確認してみます。
LDPI
DPI が LDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-ldpi フォルダに格納した画像が表示されます。MDPI
DPI が MDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-mdpi フォルダに格納した画像が表示されます。HDPI
DPI が HDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-hdpi フォルダに格納した画像が表示されます。XHDPI
DPI が XHDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-xhdpi フォルダに格納した画像が表示されます。XXHDPI
DPI が XXHDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-xxhdpi フォルダに格納した画像が表示されます。XXXHDPI
DPI が XXXHDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-xxxhdpi フォルダに格納した画像が表示されます。というような感じで、 それぞれの DPI の Drawable フォルダに画像を
入れておくと Android が DPI にあわせて画像を切替えてくれます。
(DPI を指定せずに drawable に画像を格納する場合には mdpi の画像として扱われます)このように Android では DPI ごとに用意した画像を表示する仕組みが整っており、
正しく利用すれば最適な品質で画像を表示することができるようになっています。デバイスの DPI にあったリソースがない場合、リスケールされる
それじゃ全部の Drawable フォルダに画像を入れて、
おかなきゃいけないのかというとそうでもないみたいです。デバイスの DPI にあった Drawable フォルダに画像がない場合は、
別の DPI の Drawable フォルダに格納されている画像を選択するそうです。
しかもこの場合は リソースを表示する際にデバイスのDPIにあったサイズにリスケールしてくれます。準備
表示するリソースを準備する
次の手順で drawable-xxxhdpi にのみ画像を格納します。
画像は先程のものと同じものを利用します。
res
フォルダをエクスプローラなどで開くDrawable
フォルダを作成し、dot.png
という名称で格納する。
Drawable フォルダ ピクセル 画像 drawable-xxxhdpi 192x192 画像を表示するアプリを作成する
先程と同じアプリを利用します。
実践
準備が整いましたので DPI が違うデバイスでどのように表示が変わるか確認していきます。
LDPI
DPI が LDPI であるデバイスでアプリを起動してみます。
XXXHDPI に格納した画像が LDPI にあわせてリスケールされて小さくなってます。HDPI
DPI が HDPI であるデバイスでアプリを起動してみます。
XXXHDPI に格納した画像が HDPI にあわせてリスケールされて小さくなってます。というような感じで 他の Drawable フォルダに画像がある場合は、
それを選択しまた DPI に合わせてリスケールしてくれます。このようにリスケールをしてくれるので雑に Drawable フォルダを
管理しててもおおよそは問題なく画像を表示してくれるはずです。
ですが画像が意図しない大きさで表示されることにつながるので注意が必要です。全ての DPI で同じリソースを使うには nodpi に入れる
全ての DPI で同じ Drawable 画像を使いたいんだけどってときもあると思います。
そういうときは drwable-nodpi に画像を入れることで全ての DPI で同じサイズの画像を利用できます。準備
表示するリソースを準備する
次の手順で drawable-nohdpi にのみ画像を格納します。
画像は先程のものと同じものを利用します。
res
フォルダをエクスプローラなどで開くDrawable
フォルダを作成し、dot.png
という名称で格納する。
Drawable フォルダ ピクセル 画像 drawable-nohdpi 1000x1000 画像を表示するアプリを作成する
先程と同じアプリを利用します。
実践
準備が整いましたので DPI が違うデバイスでどのように表示が変わるか確認していきます。
LDPI
DPI が LDPI であるデバイスでアプリを起動してみます。
意図したとおりリスケールされていない画像が表示されています。HDPI
DPI が HDPI であるデバイスでアプリを起動してみます。
意図したとおりリスケールされていない画像が表示されています。という感じで DPI が異なるデバイスでもリスケールせずに同じ画像を表示できます。
なので特別な理由で同じ画像を使いたいときは drawable-nodpi が使えます。おわりに
次のような特徴を理解して、とりあえず Drawable フォルダに画像を放り込むのはやめましょう。
- DPI ごとに用意される Drawable フォルダに画像を格納することで、
画面の大きさごとに最適化された画像を表示することができる。- DPI を指定せずに drawable に画像を格納すると mdpi の画像として取り扱われる。
- デバイスの DPI にあった Drawable フォルダに画像がない場合は、
別の Drawable フォルダの画像が適応される。
その際には画像はリスケールされるので注意が必要です。- 全 DPI で同じリソースを利用した場合には drawable-nodpi に画像を格納すればよい。
参考文献
- 投稿日:2019-12-11T07:46:59+09:00
[Android]画像をとりあえず Drawable フォルダに放り込むのをやめよう
はじめに
Android ではデバイスの表示画面に応じた、ビットマップ画像を格納する仕組みが用意されています。
それがぞくに言う Drawable フォルダというやつで、DPI に応じて画像を格納できるフォルダが用意されています。
密度修飾子 Drawable フォルダ DPI値 ピクセル比 スケーリング比 説明 LDPI drawable-ldpi 〜120 36x36 0.75 低密度(ldpi)の画面(〜120dpi)に適応するリソース MDPI drawable 〜160 48x48 1.00 MDPI drawable-mdpi 〜160 48x48 1.00 中密度(mdpi)の画面(〜160dpi)に適応するリソース HDPI drawable-hdpi 〜240 72x72 1.50 高密度(hdpi)の画面(〜240dpi)に適応するリソース XHDPI drawable-xhdpi 〜320 96x96 2.00 超高密度(xhdpi)の画面(〜320dpi)に適応するリソース XXHDPI drawable-xxhdpi 〜480 144x144 3.00 超超高密度(xxhdpi)の画面(〜480dpi)に適応するリソース XXXHDPI drawable-xxxhdpi 〜640 192x192 4.00 超超超高密度(xxxhdpi)の画面(〜640dpi)に適応するリソース NODPI drawable-nodpi - - - すべての密度に適用するリソース 今までなるほど〜という感じで、個人開発では適当な Drawable に画像を放り込んでいました。
そのような感じで適当な運用をしていたらレイアウトが崩れなどの問題が起きたので、
ここらで Drawable の仕組みについて調べてまとめてみたいと思います。画面の DPI に応じたリソースに切り替えてくれる
Drawable フォルダは画面の DPI ごとにフォルダが区切られており、
Drawable フォルダに各画面のビットマップを格納しておけば、
画面の DPI に応じたリソースに自動で切り替えてくれるようになっています。準備
表示するリソースを準備する
次の手順で 各 Drawable フォルダに画像を格納して、DPI の違う画面ごとにリソースを準備します。
今回は表示が切り替わっているが変わりやすいように、画像にピクセル比を載せたものを用意します。
res
フォルダをエクスプローラなどで開くDrawable
フォルダを作成し、dot.png
という名称で格納する。
Drawable フォルダ ピクセル 画像 drawable-ldpi 36x36 drawable-mdpi 48x48 drawable-hdpi 72x72 drawable-xhdpi 96x96 drawable-xxhdpi 144x144 drawable-xxxhdpi 192x192 画像を表示するアプリを作成する
次の手順で Drawable フォルダに格納した画像である、
dot.png
ファイルを表示するアプリケーションを作成します。
activity_main.xml
を開く- 画面中央に
ImageView
を配置し、src
に@drawable/dot
を指定する。activity_main.xml<?xml version="1.0" encoding="utf-8"?> <FrameLayout 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=".MainActivity"> <ImageView android:layout_width="200dp" android:layout_height="200dp" android:layout_gravity="center" android:src="@drawable/dot"/> </FrameLayout>実践
準備が整ったので、DPI が異なるデバイスでアプリを起動して動作を確認してみます。
LDPI
DPI が LDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-ldpi フォルダに格納した画像が表示されます。MDPI
DPI が MDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-mdpi フォルダに格納した画像が表示されます。HDPI
DPI が HDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-hdpi フォルダに格納した画像が表示されます。XHDPI
DPI が XHDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-xhdpi フォルダに格納した画像が表示されます。XXHDPI
DPI が XXHDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-xxhdpi フォルダに格納した画像が表示されます。XXXHDPI
DPI が XXXHDPI であるデバイスでアプリを起動してみます。
意図したとおり drwable-xxxhdpi フォルダに格納した画像が表示されます。というような感じで、 それぞれの DPI の Drawable フォルダに画像を
入れておくと Android が DPI にあわせて画像を切替えてくれます。
(DPI を指定せずに drawable に画像を格納する場合には mdpi の画像として扱われます)このように Android では DPI ごとに用意した画像を表示する仕組みが整っており、
正しく利用すれば最適な品質で画像を表示することができるようになっています。デバイスの DPI にあったリソースがない場合、リスケールされる
それじゃ全部の Drawable フォルダに画像を入れて、
おかなきゃいけないのかというとそうでもないみたいです。デバイスの DPI にあった Drawable フォルダに画像がない場合は、
別の DPI の Drawable フォルダに格納されている画像を選択するそうです。
しかもこの場合は リソースを表示する際にデバイスのDPIにあったサイズにリスケールしてくれます。準備
表示するリソースを準備する
次の手順で drawable-xxxhdpi にのみ画像を格納します。
画像は先程のものと同じものを利用します。
res
フォルダをエクスプローラなどで開くDrawable
フォルダを作成し、dot.png
という名称で格納する。
Drawable フォルダ ピクセル 画像 drawable-xxxhdpi 192x192 画像を表示するアプリを作成する
先程と同じアプリを利用します。
実践
準備が整いましたので DPI が違うデバイスでどのように表示が変わるか確認していきます。
LDPI
DPI が LDPI であるデバイスでアプリを起動してみます。
XXXHDPI に格納した画像が LDPI にあわせてリスケールされて小さくなってます。HDPI
DPI が HDPI であるデバイスでアプリを起動してみます。
XXXHDPI に格納した画像が HDPI にあわせてリスケールされて小さくなってます。というような感じで 他の Drawable フォルダに画像がある場合は、
それを選択しまた DPI に合わせてリスケールしてくれます。このようにリスケールをしてくれるので雑に Drawable フォルダを
管理しててもおおよそは問題なく画像を表示してくれるはずです。
ですが画像が意図しない大きさで表示されることにつながるので注意が必要です。全ての DPI で同じリソースを使うには nodpi に入れる
全ての DPI で同じ Drawable 画像を使いたいんだけどってときもあると思います。
そういうときは drwable-nodpi に画像を入れることで全ての DPI で同じサイズの画像を利用できます。準備
表示するリソースを準備する
次の手順で drawable-nohdpi にのみ画像を格納します。
画像は先程のものと同じものを利用します。
res
フォルダをエクスプローラなどで開くDrawable
フォルダを作成し、dot.png
という名称で格納する。
Drawable フォルダ ピクセル 画像 drawable-nohdpi 1000x1000 画像を表示するアプリを作成する
先程と同じアプリを利用します。
実践
準備が整いましたので DPI が違うデバイスでどのように表示が変わるか確認していきます。
LDPI
DPI が LDPI であるデバイスでアプリを起動してみます。
意図したとおりリスケールされていない画像が表示されています。HDPI
DPI が HDPI であるデバイスでアプリを起動してみます。
意図したとおりリスケールされていない画像が表示されています。という感じで DPI が異なるデバイスでもリスケールせずに同じ画像を表示できます。
なので特別な理由で同じ画像を使いたいときは drawable-nodpi が使えます。おわりに
次のような特徴を理解して、とりあえず Drawable フォルダに画像を放り込むのはやめましょう。
- DPI ごとに用意される Drawable フォルダに画像を格納することで、
画面の大きさごとに最適化された画像を表示することができる。- DPI を指定せずに drawable に画像を格納すると mdpi の画像として取り扱われる。
- デバイスの DPI にあった Drawable フォルダに画像がない場合は、
別の Drawable フォルダの画像が適応される。
その際には画像はリスケールされるので注意が必要です。- 全 DPI で同じリソースを利用した場合には drawable-nodpi に画像を格納すればよい。
参考文献
- 投稿日:2019-12-11T02:36:08+09:00
【Android】MVPでQiitaアプリを作ってみる・そしてポエム
はじめに
今年の四月から今月までの八ヶ月間、大学の部活でチーム開発をしました。この記事で、今回の体験で学んだことを主に技術的な部分とチーム開発で学んだ事の二つに分けて説明していきます。初投稿なので色々な部分で拙さがにじみ出ていると思いますが、ご容赦ください。
技術面
まず、今回のチーム開発とTechTrainサービスを利用して学んだことの総括としてQiitaAPIを利用して記事を取得するアプリケーションを作成してみました。これを用いて以下の説明をします。
https://github.com/Wyutaka/QiitaAPITest/MVPの前に
個人的なデザインパターンに関する見解です。すでに理解しているという方は読み飛ばしてください。
Androidのアーキテクチャには、MVC(Model-View-Controller)、MVP(Model-View-Presenter)、Clean Architectureなど様々なデザインパターンが有ります。しかし、
そもそもデザインパターンはなぜ必要なのでしょうか。
- 関心の分離
- テスタビリティの向上
- 複数人での開発
- Activityの肥大化
といった問題を解消するためにデザインパターンがあります。あるデザインパターンに則ることで、ファイル名やクラス名が一元化され統一感が生まれます。この一元化により、例えば複数人で開発する時、自分が他の人の書いたコードを全て読まなくても、「このクラスにはこういう名前がついている。なら、このクラスにはこういう処理が書かれているはずだ」と見立てることができます。また、関心を分離させることでAcitiviyのコード量が減少し、肥大化を防ぐことができます。
つまり、デザインパターンを採用することにはあるルールを決めることで、可読性を向上させるというメリットが有ります。
参考MVP(Model-View-Presenter)とは?
- アーキテクチャにおけるデザインパターンの一つ
- Model
- データクラスとのやり取り(APIでリモートからデータを取得したり、ローカルDBからデータを取得したり)
- 図でのREPOSITORY部分
- View
- ActivityやFragment
- Presenterにビジネスロジックを委託(ViewでAPIを叩くとかはしない)
- Presenter
- ビジネスロジックを担当(Modelの操作等)
Model
QiitaAPIの仕様書を参考にしてModelを定義します。
ItemModel.ktdata class ItemModel( val id: String?, val title: String?, val body: String?, val url: String?, val comments_count: Int?, val likes_count: Int?, val created_at: String?, val user: User? )Presenter用にRepositoryを作成します。
ListRepositoryImpl.ktclass RemoteListRepositoryImpl( private val api: QiitaApi ) : RemoteListRepository { override suspend fun getlist(searchWord: String): List<ItemModel> = api.getlists(searchWord) }View
ViewはPresenterにインターフェースを経由してViewのイベントをpresenterに通知するので、専用のViewを作成します。
QiitaListViewinterface QiitaListView : LifecycleScopeSupport{ fun showDetail(view: View, position: Int) fun showList(list: List<ItemModel>) }ActivityにViewを実装します。showListはAPIを利用して一覧を取得する必要があるので、発火するタイミングはpresenterに委託しています。
QiitaListActivityclass QiitaListActivity : AppCompatActivity(), QiitaListView { private lateinit var qiitaListRecyclerViewAdapter: QiitaListRecyclerViewAdapter private lateinit var qiitaListRecyclerView: CardRecyclerView private lateinit var presenter: QiitaListPresenter override val scope = LifecycleScope(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_qiita_list) presenter = QiitaListPresenter(this, this) qiitaListRecyclerView = qiita_item_list qiitaListRecyclerViewAdapter = QiitaListRecyclerViewAdapter(this, this) qiitaListRecyclerView.adapter = qiitaListRecyclerViewAdapter qiitaListRecyclerView.layoutManager = LinearLayoutManager(this) search_menu_search_view.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { presenter.showList(requireNotNull(query)) return false } override fun onQueryTextChange(newText: String?): Boolean { return true } }) } override fun onDestroy() { presenter.onDestroy() super.onDestroy() } override fun showDetail(view: View, position: Int) { val builder = CustomTabsIntent.Builder() val customTabsIntent = builder.build() customTabsIntent.launchUrl( this, Uri.parse(qiitaListRecyclerViewAdapter.getItem(position).url) ) } override fun showList(list: List<ItemModel>) { qiitaListRecyclerViewAdapter.update(list) } }Presenter
Presenterは主にrepositoryを用いてデータを取得し、Viewにデータを反映させています。
QiitaListPresenterclass QiitaListPresenter( private var view: QiitaListView?, private var context: Context, private val qiitaRepository: RemoteListRepository = context.QiitaAPIApplication.qiitaListRepository ) { fun onDestroy() { view = null } fun showList(text: String) { view?.bindLaunch { val list = qiitaRepository.getlist(text) view?.showList(list) } } }技術面まとめ
デザインパターンを用いることで、1ファイル毎のコード量の増加を防ぐだけでなく、機能ごとのテストがしやすくなり、バグの発見も容易になります。今後のチーム開発において必ず役に立つと思うので、今後積極的に使っていきたいと思います。
参考
Api
https://qiita.com/api/v2/docshttps://github.com/android/architecture-samples/tree/todo-mvp
https://dev.classmethod.jp/smartphone/android/android-mvp/
recyclerview
https://qiita.com/kubode/items/92c1190a6421ba055cc0チーム開発振り返り
先述したとおり、八ヶ月間学校の部活でチーム開発をしました。
今回のチーム開発では、Android,iOS,Serverの3つのグループに分割し、自分はAndroidのグループに入り、一つ上の先輩と一緒に開発をしました。その中でこうするべきだったと反省するべきシーンがいくつかあったので、それらを備忘録としてこの記事に残しておきます。主体性・協調性
当初は先輩が先行してアーキテクチャを選定し、その後の実装は二人で分割して行うという流れで開発をする予定でした。しかし、自分の開発が遅れたために、作業量が先輩に偏ってしまう問題が発生しました。自分の開発が遅れた原因は主に主体性、協調性の2つにありました。
プロジェクトが始動した頃、自分はアーキテクチャを全く知らない初心者でしたので、アーキテクチャの選定については先輩やメンターの方に丸投げしていました。しかし、最終的には自分も決められたパターンに沿って開発していかなければいけません。結局、コードを実装する直前になってそのアーキテクチャについて勉強する羽目になり、思うようにコードがかけませんでした。自分からアンテナを張って周りが何をしているのか、なぜそうしているのかを把握することが大切です。こうすることで、後になって「あれ、これってどういう仕様だったっけ」というような状況になったときに全然自分の思っていた仕様と違う、みたいな説明する側もされる側も不幸になるような事態を減らすことができます。無理をしない
周りに聞くのが申し訳ないと思って、中々聞けずに最後まで自分で調べる、みたいなことはよくあると思います。
しかし、その中には最後まで自分でどれだけ調べてもわからない、または理解するのに相当な時間を費やした、という時もあると思います。なので、ある程度(15〜30分)自分で調べてわからないときは周りに聞くことにするほうがいいです。むしろ、周りに聞く事はチーム全体のコミュニケーションを活発にさせるメリットがあると考えましょう。
ある時、ひょんなことから部活の同期に「お前しんどそう、でも何してるのかわからないから何をしてあげられるのかがわからない」と言われました。自分が周りを心配させていることがその時初めてわかりました。つまり、申し訳ないと思って無理に調べて周りに尋ねないという行為は、逆にチーム全体のムードをさげてしまう可能性があることがわかりました。わからない事を慎重に、積極的に尋ねる
では、いざ周りに尋ねるときには何に気をつければいいでしょうか。
まず、答えてくれる人の負担を出来る限り減らすことを目標に質問することが大切だと思います。その為に、
- 自分のレベルを伝える
- 何が問題かを概要で伝える
- 自分が行った作業手順を伝える
- エラーメッセージを載せる
- やりたかった事と実際の挙動を伝える
- 自分が何を調べたかを伝える(記事のurlなど)
- 問題があると思われるコードを提示する
これらを意識することで、答えてくれる人が質問する側がどこでミスをしているかの検討がつけやすくなります。また、こういったテンプレートを作成しておくことで、自分も質問をするときに楽ができます。この記事がとても役に立ったので、ぜひ参考にしてみてください。
終わりに
今回のプロジェクトを通して、失敗もたくさんありましたが、貴重な体験をさせていただきました。このプロジェクトに関わってくださった皆さんへの謝意とともに、この記事の締めとさせていただきます。
- 投稿日:2019-12-11T01:41:07+09:00
UnityのAndroidアプリにAdMob 広告を入れたときのエラーやトラブル解決法
はじめに
現在開発中のアプリでNendAdやUnityAdsを入れているのですが
調べているとAdMobが結局相場が良いとの情報があったので試しに入れることにしました。
そこで色々トラブルありましたので備忘録としてここに残しておきたいと思います。環境や利用するもの
Unityバージョン:2019.2.12f
Android周りのモジュール(NDKやらAndroid周り)も一緒にインストールしています。導入方法は基本こちらを参照してください。
プラグインは「v4.1.0」というものを取り込みました。取り込み時に出るウィンドウ
後で書いているので詳細に覚えていないのですが、取り込むと初めになにかポップアップが出てきました。
それを実行すると全く画面がすすまなかったのでキャンセルしてしまいました。あとになって考えると出たそのウィンドウはプラグインによって追加される項目
Asset→Play Services Resolver→Resolve or Force Resolve
という項目の設定だった思います。
どうやらこちらはプロジェクトにある他のプラグインで使われているNDK?やJDK?などのライブラリの依存関係を解決するものなようです。
ひとまずこのResolveを実行して上記のサイトの手順に沿ってバナーを置いてみます。
Android実機で起動できたが広告が出ない
配置したはずの広告がでませんでした。なぜ?
どうやら単純に設定が足りなかったようです。Assets→Google Mobile Ads→Setting
を実行するとInspectorに各設定が出来たようです。
またInspectorのGoogle AdMobの「Enabled」を有効にして
各プラットフォームの「App ID」を設定しました。しかし、それでも広告は出ません。
そういえば最初のエラー
ふと、プラグインの時に出てきたあのウィンドウ重要だったんじゃ…と思いました。
そしてAsset→Play Services Resolver→Resolve
を実行しましたが大した表示もでず、先程と同じです。
念の為こちらも押してみました。
Asset→Play Services Resolver→Force Resolve
すると、さっき出てたウィンドウが出てプログレスバーが動いています!
ちょっと待つと無事に終わってデータが更新されたようです。
「これで動くぞ~」と思ったらここからエラー地獄の始まりです。海外フォーラムにもあった内容
色々巡回しすぎて忘れてしまったのですが、海外のフォーラムなどでも同じ現象が出てるとありました。
ただ同じ環境の人が少なく、参考になるものはありませんでした。AdMobプラグインが新しいから?
もしかしてAdMobプラグインに対してUnityのバージョンが古いから出るのかなと思いました。
ただUnityを上げるとほかにも不具合が出そうだったので、利用しているバージョンの2019.2.12の少し後に出ているプラグインのバージョンを入れてみました。
しかし、残念ながら直らず…
JDKで直るかも
どっかのサイトでJDKも関係していて最新を入れたら直った説を発見!
最新版のJDKをオラクルからダウンロードし、
Preferences→ExternalTools→JDK Instakked with Unity
のチェックを外して
インストールしたパスに追加しましたが特に変化はなしでした。競合しているってことは?
「play-servicesほにゃらら」というファイルが競合している(複数ある)ため
先程の「Resolve」して依存関係を解決する必要がある的な発言を見つけました。そういえばNendっていう広告、Android環境でも使えてたってことはここに競合の元があるんじゃ?
さっそくエクスプローラで検索してみたら…ありました!
しかもNendフォルダの中!まだ完全移行ではないので残していましたが
使わなくなる予定なのでさくっと削除し、念の為Resolveしたところエラーは消えました。いざ、実機に送ってみるとまたエラー
地味に初歩的なエラーでちょっと時間を掛けてしまいました…
Keyデータのパスワードが全角半角が間違っているのが気づかず何回も実行しようとしたんですね。もうエラーが多すぎて疑心暗鬼がMAXになってたんでしょうね(^_^;)
とりあえずそれをクリアしたら無事広告が表示されました!
大まかな解決法
紆余曲折あって解決したこともあって複雑に書いてしまいましたが
おそらくこんな感じで直ると思います。
- GoogleのAdmobページで広告などを用意
- GoogleAdsプラグインを取り込む
- Assets→Google Mobile Ads→Settingで設定
- Nendなどの競合しそうな広告プラグインを削除
ただこの対応に2日掛けているので
Googleで設定した広告が有効になるには数日かかることもあるらしいので
少し待たないと動かないかもしれません。最後に
NendやUnityAdsみたいに簡単に入ると思ったら意外に大変でした…
iOSはまだ試していませんがまた何かトラブルがあればまた追記なり、記事にして共有したいと思います。同様の問題で苦労している人などのためになれば幸いです。
ではここまでお読みいただきありがとうございました。
- 投稿日:2019-12-11T00:11:57+09:00
[Kotlin] isEmpty(), isBlank()などの違い早見表
背景
そもそも、なぜこの記事を書こうと思ったのかと言うと、PHPのメソッドの早見表はあるのに、Kotlinが無いな・・・。って思っただけです。
文字比較の早見表
書き方のフォーマットは、この記事参考にしてます。「PHP isset, empty, is_null の違い早見表」
値 isEmpty isNotEmpty isNullOrEmpty isBlank isNotBlank isNullOrBlank orEmpty var="" true false true true false true 空文字列 var=" " false true false true false true スペース var=null Error Error true Error Error true 空文字列 var="1" false true false false true false 1 var="0" false true false false true false 0
- Errorとある箇所についてはそもそもコンパイルエラーを起こします。
- null禁止ですね
isNullOrBlank()が最強な気がしてきた・・・w
参考リンク
- Kotlin言語仕様
- PHP isset, empty, is_null の違い早見表
- Kotlin 1.3 - kotlin.CharSequence
- 投稿日:2019-12-11T00:02:18+09:00
新しいFragmentTemplateをためそう
- この記事はAndroid 初心者向け Advent Calendar 2019 の13日目の内容です
![]()
はじめに
- Android Studio 4.0 Canary バージョンで新しいFragmentTempleteが追加されました。
- 追加されたTempleteを試してどのように適応されるか確認していきましょう。
事前準備
- Android Studio 4.0.5(記載時点の最新バージョン)
- 任意のAndroidプロジェクト
追加されたFragmentTempleteは?
Android Studio 3.5 Android Studio 4.0 LoginFragment
- 名前の通り、ログイン風UIが生成されるテンプレート
追加ファイル(View関連)
- LoginFragment.kt
- Viewを構成するFragmentファイル
- fragment_login.xml
- LoginFragmentで使用しているレイアウトファイル
- LoggedInUserView.kt
- 認証済みユーザー情報
追加ファイル(ViewModel関連)
- LoginViewModelFactory.kt
- LoginViewModelを生成する為のFactoryクラス
- LoginViewModel.kt
- ViewModelの処理を実装しているクラス
- LoginResult.kt
- Repositoryの処理を返却するためのデータクラス
- LoginFormState.kt
- UIの入力状態を返却するためのデータクラス
追加ファイル(Model関連)
- LoginRepository.kt
- Repositoryの処理を実施するクラス
- LoginDataSource.kt
- ログイン処理を実施するクラス
- LoggedInUser.kt
- ログイン情報を返却するデータクラス
- Result.kt
- DataSourceからの処理結果を返却するクラス
良い点
![]()
- MVVMを意識した形でクラスの構成がされている
![]()
- ログイン処理の役割を分担をどうするか参考に
![]()
- ログイン結果をResultクラスの形で返している部分が
![]()
SettingFragment
- 設定画面を簡単に生成できるテンプレート
注意
![]()
テンプレート適応後に必要となるライブラリの設定が書き込まれないので注意
app/build.gradle
に以下を追加する必要がありますdependencies { implementation 'androidx.preference:preference:1.0.0' }追加ファイル
- SettingsFragment.kt
- PreferenceFragmentCompat を継承したクラス
- XMLのレイアウト要素を読み込ませることで、画面が作成される
- root_preferences.xml
- レイアウト要素を構成しているファイル
- 各要素の
key
を元に自動的に設定が保存される良い点
![]()
- PreferenceFragmentCompatがすぐ理解できる
![]()
まとめ
- すべてのFragmentTemplateを試すことはできていませんが、実装の参考にすることはできると思います!
- 気になるテンプレートがあれば試してみましょう。
これからも楽しいアプリケーション開発を