- 投稿日:2019-10-11T23:46:56+09:00
BLEデバイスのスキャンをkotlin coroutineのcallbackFlowでやる
はじめに
勉強のためAndroidでBLE(BluetoothLowEnergy)デバイスのスキャンを行う際のコールバック処理に、kotlin coroutineのcallbackFlowを使ったサンプルアプリを作ってみました。
サンプルアプリのgithubレポジトリ
https://github.com/cnaos/BleDeviceScanExample通常のBLEデバイスのスキャン方法
BLEデバイスのスキャン処理部分だけ取り出すとこんな感じ。
// スキャン時間 private val SCAN_PERIOD: Long = TimeUnit.MILLISECONDS.convert(20, TimeUnit.SECONDS) // スキャンのタイムアウト処理用 private val handler: Handler = Handler() // デバイススキャンコールバック private val mLeScanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { // スキャンしたBLEデバイスの処理 addDevice(result) } } fun startDeviceScan() { // タイムアウト処理の仕込み handler.postDelayed({ scanner.stopScan(mLeScanCallback) }, SCAN_PERIOD) // BLEデバイスのスキャン開始 scanner.startScan(mLeScanCallback) }スキャンのコールバック
// デバイススキャンコールバック private val mLeScanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { // スキャンしたBLEデバイスの処理 addDevice(result) } }BLEデバイスを検出した際にBluetoothLeScannerから呼ばれるcallback用のオブジェクト(mLeScanCallback)を用意します。
スキャン処理
fun startDeviceScan() { /// 省略 /// // BLEデバイスのスキャン開始 scanner.startScan(mLeScanCallback) }BluetoothLeScannerのstartScanメソッドの引数にmLeScanCallbackを渡してstartScanメソッドを呼び出すとBLEデバイスのスキャンが実行されます。
BluetoothLeScannerがBLEデバイスを検出されると、mLeScanCallbackのonScanResultメソッドが呼ばれるので、
onScanResultメソッドで検出したBLEデバイスを処理します。
例ではaddDeviceメソッドで処理しています。タイムアウト処理
fun startDeviceScan() { // タイムアウト処理の仕込み handler.postDelayed({ scanner.stopScan(mLeScanCallback) }, SCAN_PERIOD) // BLEデバイスのスキャン開始 }また、一定時間経過したあとにBLEデバイスのスキャンを停止するために
HandlerをつかってBluetoothLeScannerのstopScanメソッドを呼び出します。callbackFlowを使った方法
Flowを提供する側の実装
// 定数 companion object { // スキャン時間 private val SCAN_PERIOD: Long = TimeUnit.MILLISECONDS.convert(20, TimeUnit.SECONDS) } /** * ログ出力用のメソッド */ fun log(functionName: String, msg: String = "") = Log.i(TAG, "[${Thread.currentThread().name}] $functionName $msg") /** * BLEデバイスのスキャン結果をFlowで取得する */ fun deviceScanFlow( scanner: BluetoothLeScanner ): Flow<ScanResult> = callbackFlow { val functionName = "deviceScanFlow()" val mLeScanCallback = object : ScanCallback() { // BLEデバイスがスキャンされると呼ばれる。 override fun onScanResult(callbackType: Int, result: ScanResult) { if (channel.isClosedForSend) { return } // callbackFlowのchannelにScanResultを送る offer(result) } } // BLEデバイスのスキャン処理開始 scanner.startScan(mLeScanCallback) // 一定時間経過したらchannelをcloseするタイマーを仕掛ける launch { delay(SCAN_PERIOD) log(functionName, "channel close delay") channel.close() } // callbackFlowのchannelが閉じるか、利用側でjobがキャンセルされた場合の処理 awaitClose { log(functionName, "channel closed") scanner.stopScan(mLeScanCallback) stopDeviceScan() } }Flowを利用する側の実装
// BLEデバイスのスキャンを行うJob private var deviceScanJob: Job? = null fun startDeviceScan() { log("startDeviceScan") // RuntimePermissionとかの処理 // BLEデバイスのcallbackFlowを作ってそこからデータを受け取る deviceScanJob = viewModelScope.launch(Dispatchers.IO) { val scanFlow = deviceScanFlow(scanner) scanFlow.buffer().collect { addDevice(it.device) } } } /** * BLEデバイスのスキャンJobをキャンセルする */ fun cancelDeviceScanJob() { log("cancel deviceScanJob") deviceScanJob?.cancel() }解説
私のイメージだと、コールバック処理をcallbackFlowで作ったFlow内に閉じ込めて、使う側ではFlowで流れてきたコールバックの結果を処理するという感じです。
スキャン処理
val mLeScanCallback = object : ScanCallback() { // BLEデバイスがスキャンされると呼ばれる。 override fun onScanResult(callbackType: Int, result: ScanResult) { if (channel.isClosedForSend) { return } // callbackFlowのchannelにScanResultを送る offer(result) } } // BLEデバイスのスキャン処理開始 scanner.startScan(mLeScanCallback)通常の方法だと、onScanResult内で直接スキャン結果を処理していましたが、callbackFlowを使った方法では、検出したBLEデバイスの情報(ScanResult)をofferメソッドを使ってかcallbackFlowが持っているchannelに送ります。
スキャン結果の受信処理
// BLEデバイスのcallbackFlowを作ってそこからデータを受け取る deviceScanJob = viewModelScope.launch(Dispatchers.IO) { val scanFlow = deviceScanFlow(scanner) scanFlow.buffer().collect { addDevice(it.device) } }送ったスキャン結果はFlowを使う側でcollectで受信して処理します。
タイムアウト処理
// 一定時間経過したらchannelをcloseするタイマーを仕掛ける launch { delay(SCAN_PERIOD) log(functionName, "channel close delay") channel.close() }通常のBLEデバイスのスキャンの例ではHandlerを使って実装していたスキャンのタイムアウト処理をcoroutineのdelayを使って実装しています。
タイマーでBluetoothLEScannerのstopScanを呼び出す代わりに、タイマーでcallbackFlowのchannelをcloseします。コールバックの解除処理
// callbackFlowのchannelが閉じるか、利用側でjobがキャンセルされた場合の処理 awaitClose { log(functionName, "channel closed") scanner.stopScan(mLeScanCallback) stopDeviceScan() }最後のawaitCloseのブロックは、
callbackFlowのchannelがcloseされた場合、または、callbackFlowを含むJobがキャンセルされた場合に、ブロック内の処理が実行されます。参考にさせていただいたもの
- 投稿日:2019-10-11T20:37:09+09:00
初めてのAndroidアプリで躓いたところ【1:アプリ編】(プログラミング初学者)
アプリの紹介・経緯とか
Androidアプリの初めての成果物として、テキストを読み上げるアラームを作りました。
既存のテキスト読み上げアラームは「アラームが起動中」に読み上げるので、
全文を聴き終える前に停止するのを防ぐ意味で「アラームを停止後」に読み上げるように作りました。
このアラームを作る過程でつまづいた、分かり辛かったことについて書きます。
作成したアプリはこちら
音声編
TextToSpeecnの初期化・リソースの解放
onCreate中で
TextToSpeech(this, this)
で初期化する必要があります。
また、onDestroyでリソースを解放しないとエラーが出ます。(ServiceConnectionLeaked)AlarmBootActivity.ktoverride fun onCreate(savedInstanceState: Bundle?) { .... // TextToSpeechの初期化、初期設定 tts = TextToSpeech(this, this) .... } override fun onDestroy() { super.onDestroy() tts.shutdown() // ttsのリソースを解放 .... }Androidの音量設定と分ける
Androidの端末の音量設定を操作するので、
onCreate
・onResume
で端末の音量を変数に保持します。
onStop
・onPause
・onDestroy
で元の音量に戻し、onResume
でアプリの設定値に戻します。
しかし、onCreateから起動した場合は、onRsumeでは端末の音量を取得しないようにします。
(アプリで設定した音量を取得してしまうので)AlarmBootActivity.ktprivate var preMusicVol: Int? = null // 端末の元の音量設定(アラーム音: メディアの音量) private var preVoiceVol: Int? = null // 端末の元の音量設定(テキスト読み上げ: 着信音の音量) private var musicVol: Int? = null // アラームの音量 private var voiceVol: Int? = null // 声の音量 private var onCreateMark: Boolean? = null // onCreateからの起動かを判定 override fun onCreate(savedInstanceState: Bundle?) { .... // 現在の端末の音量設定を格納(onDestroy()・onPause()・onStop()で元に戻す) getPreVolumeConfig() // onCreateから起動したことを確認する onCreateMark = true .... } override fun onPause() { super.onPause() when (onCreateMark) { true -> onCreateMark = false false -> { // 現在の端末の音量設定を取得 getPreVolumeConfig() // シークバーの音量設定に戻す(アラーム音) musicVol = musicVolSeekbar.progress // シークバーの音量設定に戻す(テキスト読み上げ音) voiceVol = voiceVolSeekbar.progress } } } override fun onStop() { super.onStop() preVolumeSet() // 元の音量設定に戻す } override fun onDestroy() { super.onDestroy() preVolumeSet() // 元の音量設定に戻す } // 現在の端末の音量設定を格納(onDestroy()・onPause()・onStop()で元に戻す) private fun getPreVolumeConfig() { preMusicVol = am.getStreamVolume(STREAM_NOTIFICATION) // アラーム音 preVoiceVol = am.getStreamVolume(STREAM_MUSIC) // テキスト読み上げ音 } // アラーム音・テキスト読み上げ音の音量設定を戻す private fun preVolumeSet() { am.setStreamVolume(STREAM_NOTIFICATION, preMusicVol!!, 0) am.setStreamVolume(STREAM_MUSIC, preVoiceVol!!, 0) }カレンダー編
現在時刻を取得する場合、
毎回Calendar.getInstance()
でカレンダーを取得する必要があります。
timeInMillies()
はカレンダーを取得した時点での現在時刻を取得するので、
数秒前にCalendar.getInstance()
で取得した場合、数秒前が現在時刻となるので注意。アラーム編
再起動時にアラームを再設定する(ダイレクトブート)
Android起動時のダイレクトブートモードで、アラームを再設定するReceiverを呼び出します。
呼び出すReceiverにはAndroidManifestで以下のように記述します。AndroidManifest.xml<receiver android:name=".DirectBootReceiver" android:directBootAware="true" android:enabled="true"> <intent-filter> <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter> </receiver>指定したReceiverでDBから「アラーム」がONになっているデータだけ取得してアラームを再設定します。
※ダイレクトブートで呼び出したReceiverからアクティビティを呼び出すことはできますが、Serviceを呼び出すことはできません。
(「サービスの開始を許可しないIntentです」と怒られます。)予約した自動スヌーズの遅延処理をキャンセルする
サービスを破棄するときに自動でスヌーズを実行する遅延処理をキャンセルします。
これを忘れるとサービスを破棄された後でも遅延処理は実行されてしまいます。ForegroundService.ktprivate var runSnooze: Runnable? = null // 遅延処理でアラームを自動スヌーズ val snoozeHandler = Handler() runSnooze = Runnable { // スヌーズ実行 val snoozeIntent = Intent(this, AlarmSnoozeBroadcastReceiver::class.java) sendBroadcast(snoozeIntent) } snoozeHandler.postDelayed(runSnooze!!, 60000) override fun onDestroy() { super.onDestroy() // タップしてアラームを停止させた場合、1分後のスヌーズ処理をキャンセル snoozeHandler.removeCallbacks(runSnooze!!) }setRepeatが使えない。
setRepeat
はAPI19以降から不正確なので推奨されていません。
なのでアラームを止めた後に、
同じrequestCodeを使ってset()
・setExact()
・setAlarmClock()
でアラームを再設定しました。Android10ではActivityをバックグランドから呼べない
Android9.0以下は、アラーム起動時にActivityを呼ぶようにしていますが、
Android10はバックグランドからActivityを呼べないのでServiceを呼ぶようにしています。
サービスもstartForegroundService()
で呼ぶ必要があります。
ForegroundServiceについてはこちらを参考にしました。
Foreground Serviceの基本通知の削除
setAutoCancel(true)
やNotification.FLAG_AUTO_CANCEL
では通知をタップしても何故か消えなかったので、チャンネルIDを指定して通知を削除するようにしました。ForegroundService.ktoverride fun onDestroy() { super.onDestroy() // アラームを停止させたら、サービス終了時に通知を消す。 val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.deleteNotificationChannel("alarm_notification") // アラームの通知を削除 }Realm・RecyclerView編
requestCodeをどうやって分けるか
それぞれ月〜日曜日の
requestCode
を保存するカラムを用意しました。
配列で保存しようとも考えましたが、
RDBは基本的に配列を記録させられないので単一のデータごとに保存しました。
※1桁の数字だけを配列にするなら、文字列にして保存した後にcontains('2')
で検索すれば取得できなくもありません。
削除の演出
RecyclerViewに表示されている登録したアラームを削除するときに、
notifyItemRemoved()
とnotifyDataSetChanged()
で削除&更新ができます。
(最初なぜか上手く実行できませんでした)CustomRecyclerViewAdapter.ktoverride fun onBindViewHolder(holder: ViewHolder, position: Int) { .... notifyItemRemoved(position) // アラームの位置を取得して消す notifyDataSetChanged() // Realmのデータが変更されたら自動的に更新する .... }RealmのDB内容の確認
Realm Studio(Mac)での確認方法はいくつか解説しているサイトがありましたが、
自分はあまりうまくいきませんでした。
最終的にこれで確認できました。↓
Realm StudioでDBの内容を確認する方法【Android・Mac】まとめ
初めてのAndroidアプリ開発ということで、
Androidの特有の機能やバージョン間の違いを理解するのに苦労しました。次はアプリのリリース編を書きます。
初めてのAndroidアプリで躓いたところ【2:リリース編】(プログラミング初学者)
- 投稿日:2019-10-11T20:21:54+09:00
Realm StudioでDBの内容を確認する方法【Android・Mac】
Realm Studioでデータベースを確認する方法について紹介します。
他のサイトで解説している通りでは上手くいかなかったため、
僕の場合は最終的にこのような方法になりました。
参考になれば幸いですm(_ _)m※環境はAndroid Studio3.5
adbコマンドの有効化
adbコマンドを実行して、
bash: adb: command not foundと出る場合は、adbコマンドを有効化する必要があります。
Android Studioの「ファイル」→「その他の設定」→「デフォルト・プロジェクト構造…」にある、
Android SDK ロケーションのファイルパスをコピーします。
Android Studioのターミナルに、コピーしたファイルパスを以下のように入力します。
export PATH=$PATH:/Users/ユーザー名/Library/Android/sdk/platform-toolsこれでadbコマンドが使用できます。
AndroidのDBをインポートする
続いてAndroid Studioのターミナルに、
adb kill-server adb start-server adb shellを入力します。
次にプロジェクト名をコピーして以下のように実行します。
run-as com.example.hoge cd files cat default.realm > /sdcard/export_db.realm3行目の
export_db
の部分は任意の名前で構いません。続いて、「control + d」を2回入力してshellを終了し、
adb pull /sdcard/export_db.realm ./これでDBを取得できました。
あとはRealm Studioで、プロジェクトフォルダに保存されたRealmのファイルを開けばデータの中身が見れます。
おわり
adbコマンドを有効化するところから解説している記事がなかったため書いてみました。
- 投稿日:2019-10-11T11:39:17+09:00
[Android]台風に備えて気圧を測れるアプリを作る
こんにちは。中間テスト終わりました。
今回はやばい台風が来るそうなので気圧が測れるアプリを作りたいと思います。スマホにはいろんなセンサーがついている
せっかくなので使いましょう。
というのは建前で本当は この記事 でやってることAndroidでも作れるのではと思ったのが本当の理由。紹介
端末 Pixel 3 XL Android バージョン Android 10 最低バージョン ろりぽっぷ 言語 Kotlin 実装
レイアウト
真ん中にTextViewを置いただけのものとする。
面倒なのでConstraintLayoutをそのまま使うことにする。activity_main.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=".MainActivity"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="50dp" android:id="@+id/barometer_tv" android:text="Hello World!" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>コード
Sensor.TYPE_PRESSUREが気圧計になります。
onDestroy()で登録を解除しています。
受け取るセンサーがほかにもあるときはevent?.sensor?.type
を利用して分けましょう。MainActivity.ktlateinit var sensorManager: SensorManager lateinit var sensorEventListener: SensorEventListener override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager //今回は気圧計 val sensorList = sensorManager.getSensorList(Sensor.TYPE_PRESSURE) //受け取る sensorEventListener = object : SensorEventListener { override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { //つかわん } override fun onSensorChanged(event: SensorEvent?) { //値はここで受けとる //今回は気圧計のみだからいいけどほかにも登録するときは分岐してね if(event?.sensor?.type == Sensor.TYPE_PRESSURE){ //気圧計の値 val barometer = event.values[0] //TextViewに設定 barometer_tv.text = "$barometer hPa" } } } //登録 sensorManager.registerListener( sensorEventListener, sensorList[0], //配列のいっこめ。気圧計 SensorManager.SENSOR_DELAY_NORMAL //更新頻度 ) } override fun onDestroy() { super.onDestroy() sensorManager.unregisterListener(sensorEventListener) }これでTextViewに気圧が表示されてると思います。
小数点を消す
roundToInt()
できれいになります。//値はここで受けとる //今回は気圧計のみだからいいけどほかにも登録するときは分岐してね if(event?.sensor?.type == Sensor.TYPE_PRESSURE){ //気圧計の値 val barometer = event.values[0] //整数に val barometerInt = barometer.roundToInt() //TextViewに設定 barometer_tv.text = "$barometerInt hPa\n($barometer hPa)" }以上です。
お疲れ様です。88888