- 投稿日:2020-09-14T23:53:12+09:00
【初心者向け】CameraXでAndroidのカメラアプリを作る
背景と実施したこと
cameraXというライブラリを使うとカメラアプリが爆速で出来上がるらしいと噂を聞いて挑戦。
やったことと学びの備忘録のために記事に残します。
言語はkotlinを使用。
実施した内容は、以下の公式のチュートリアル+αくらいの内容です。
したがって、この記事の内容はチュートリアルにだいたい書いてあるのですが、それすら見るのも面倒くさい人向けにざっくりで記載します。
※ちなみにですがcameraXは2020年9月現在ではベータ版だそうです。ざっと試した感じ問題なさそうですが、今後何かしらの更新等はあるかもしれません。■チュートリアル
https://codelabs.developers.google.com/codelabs/camerax-getting-started/#1■参考したサイト
https://qiita.com/chohas/items/5f4be2e202fe4d7b7be1目次
カメラのアプリは大体以下の4つのフェーズに分けて考えるとわかりやすそう。
以下がそのまま本記事の目次になります。
- カメラアプリの画面を作る
- デバイスのカメラを使用する許可をもらう
- プレビューの処理
- 実際に写真を撮って保存する処理
- まとめ
カメラアプリの画面を作る
兎にも角にもこれがないと始まらない。
build.gradleとmain.xmlは以下の通り
build.gradle(project)// Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext.kotlin_version = "1.3.72" repositories { google() jcenter() } dependencies { classpath "com.android.tools.build:gradle:4.0.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() jcenter() } } task clean(type: Delete) { delete rootProject.buildDir }build.gradle(app)apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' android { compileSdkVersion 30 buildToolsVersion "30.0.0" defaultConfig { applicationId "com.example.mycameraapp" minSdkVersion 21 targetSdkVersion 30 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.1.0' implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' def camerax_version = "1.0.0-beta03" // CameraX core library using camera2 implementation implementation "androidx.camera:camera-camera2:$camerax_version" // CameraX Lifecycle Library implementation "androidx.camera:camera-lifecycle:$camerax_version" // CameraX View class implementation "androidx.camera:camera-view:1.0.0-alpha10" }意外とあまり追加する要素はない。
activity_main.xml<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <Button android:id="@+id/camera_capture_button" android:layout_width="100dp" android:layout_height="100dp" android:layout_marginBottom="50dp" android:scaleType="fitCenter" android:text="Take Photo" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintBottom_toBottomOf="parent" android:elevation="2dp" /> <androidx.camera.view.PreviewView android:id="@+id/viewFinder" android:layout_width="match_parent" android:layout_height="match_parent" /> </androidx.constraintlayout.widget.ConstraintLayout>画面は「take photo」のボタンがあるだけのシンプルなもの。
デバイスのカメラを使用する許可をもらう
AndroidManifest.xmlに以下を追加することでカメラを使えるようになる。
AndroidManifest.xml<uses-feature android:name="android.hardware.camera.any" /> <uses-permission android:name="android.permission.CAMERA" />さらにMainActivityに以下を追加する
MainActivity.ktoverride fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String>, grantResults: IntArray) { if (requestCode == REQUEST_CODE_PERMISSIONS) { if (allPermissionsGranted()) { startCamera() } else { Toast.makeText(this, "Permissions not granted by the user.", Toast.LENGTH_SHORT).show() finish() } } } ///途中省略 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // Request camera permissions if (allPermissionsGranted()) { startCamera() } else { ActivityCompat.requestPermissions( this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS) } } ///途中省略 private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { ContextCompat.checkSelfPermission( baseContext, it) == PackageManager.PERMISSION_GRANTED }このあたりまではどんなカメラアプリでも共通の処理のはず
プレビューの処理
カメラ使用許可に足してユーザが同意するとstartCamera()が走る。この処理の中でプレビュー処理、つまりファインダー越しに対象を映し出すことになる。
MainActivity.ktprivate fun startCamera() { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener(Runnable { // Used to bind the lifecycle of cameras to the lifecycle owner val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() // Preview preview = Preview.Builder() .build() //image imageCapture = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .build() //画像分析のための処理. imageAnalyzer = ImageAnalysis.Builder() .build() .also { it.setAnalyzer(cameraExecutor, LuminosityAnalyzer { luma -> Log.d(TAG, "Average luminosity: $luma") }) } // Select back camera val cameraSelector = CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() try { // Unbind use cases before rebinding cameraProvider.unbindAll() // Bind use cases to camera camera = cameraProvider.bindToLifecycle( this, cameraSelector, preview,imageCapture,imageAnalyzer) preview?.setSurfaceProvider(viewFinder.createSurfaceProvider(camera?.cameraInfo)) } catch(exc: Exception) { Log.e(TAG, "Use case binding failed", exc) } }, ContextCompat.getMainExecutor(this)) }imageAnalyzer = ImageAnalysis.Builder()
内の処理はcamerax特徴でもある画像分析のための処理(ただカメラを使いたいだけなら不要なので一旦説明は割愛)ここでやっていることは
ProcessCameraProviderのインスタンスを作り、カメラのlifecycleと紐付け
(この処理のおかげで、戻るボタンやホームボタンに押した後に再度アプリにもどってもアプリを再開できる)アプリが実行されている場合のリスナーを追加し、プレビュー処理のための初期化を行う
というイメージ。
フォトフレームの追加とかARとの合成とかも多分この処理に絡めて実装すると思われる。(snowみたいな画像の加工とかもかな?)
実際に写真を撮って保存する処理
写真を取る処理は以下の通り。
チュートリアルではシャッター音がならなかったので追加している。
※逆にいうと無音カメラもこれで簡単に作れるし、
例えばシャッターを切る際になにか別の音声を入れるとかもできる。
保存する情報やディレクトリもここをいじることで変更できる。MainActivity.ktprivate fun takePhoto() { // Get a stable reference of the modifiable image capture use case val imageCapture = imageCapture ?: return // Create timestamped output file to hold the image val photoFile = File( outputDirectory, SimpleDateFormat(FILENAME_FORMAT, Locale.US ).format(System.currentTimeMillis()) + ".jpg") // Create output options object which contains file + metadata val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() //シャッター音追加のためのコード val sound = MediaActionSound() sound.load(MediaActionSound.SHUTTER_CLICK) // Setup image capture listener which is triggered after photo has been taken imageCapture.takePicture( outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { override fun onError(exc: ImageCaptureException) { Log.e(TAG, "Photo capture failed: ${exc.message}", exc) } override fun onImageSaved(output: ImageCapture.OutputFileResults) { val savedUri = Uri.fromFile(photoFile) val msg = "Photo capture succeeded: $savedUri" Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show() Log.d(TAG, msg) //シャッター音の追加 sound.play(MediaActionSound.SHUTTER_CLICK) } }) } private fun getOutputDirectory(): File { val mediaDir = externalMediaDirs.firstOrNull()?.let { File(it, resources.getString(R.string.app_name)).apply { mkdirs() } } val mediaDirS = mediaDir.toString() Log.d(TAG, "mediaDir: $mediaDirS filesDir:$filesDir" ) return if (mediaDir != null && mediaDir.exists()) mediaDir else filesDir }細かい部分は省略したので、参考のためにMainActivityの全文を載せます
サンプルコード
MainActivity.ktpackage com.example.mycameraapp import android.Manifest import android.content.pm.PackageManager import android.media.AudioAttributes import android.media.MediaActionSound import android.media.SoundPool import android.net.Uri import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.util.Log import android.widget.Toast import androidx.camera.core.* import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import kotlinx.android.synthetic.main.activity_main.* import java.io.File import java.nio.ByteBuffer import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.ExecutorService import java.util.concurrent.Executors typealias LumaListener = (luma: Double) -> Unit class MainActivity : AppCompatActivity() { private var preview: Preview? = null private var imageCapture: ImageCapture? = null private var imageAnalyzer: ImageAnalysis? = null private var camera: Camera? = null private lateinit var outputDirectory: File private lateinit var cameraExecutor: ExecutorService private lateinit var soundPool: SoundPool private var soundOne = 0 override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String>, grantResults: IntArray) { if (requestCode == REQUEST_CODE_PERMISSIONS) { if (allPermissionsGranted()) { startCamera() } else { Toast.makeText(this, "Permissions not granted by the user.", Toast.LENGTH_SHORT).show() finish() } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // Request camera permissions if (allPermissionsGranted()) { startCamera() } else { ActivityCompat.requestPermissions( this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS) } // Setup the listener for take photo button camera_capture_button.setOnClickListener { takePhoto() } outputDirectory = getOutputDirectory() cameraExecutor = Executors.newSingleThreadExecutor() } private fun startCamera() { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener(Runnable { // Used to bind the lifecycle of cameras to the lifecycle owner val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() // Preview preview = Preview.Builder() .build() //image imageCapture = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .build() //画像分析のための処理. imageAnalyzer = ImageAnalysis.Builder() .build() .also { it.setAnalyzer(cameraExecutor, LuminosityAnalyzer { luma -> Log.d(TAG, "Average luminosity: $luma") }) } // Select back camera val cameraSelector = CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() try { // Unbind use cases before rebinding cameraProvider.unbindAll() // Bind use cases to camera camera = cameraProvider.bindToLifecycle( this, cameraSelector, preview,imageCapture,imageAnalyzer) preview?.setSurfaceProvider(viewFinder.createSurfaceProvider(camera?.cameraInfo)) } catch(exc: Exception) { Log.e(TAG, "Use case binding failed", exc) } }, ContextCompat.getMainExecutor(this)) } private fun takePhoto() { // Get a stable reference of the modifiable image capture use case val imageCapture = imageCapture ?: return // Create timestamped output file to hold the image val photoFile = File( outputDirectory, SimpleDateFormat(FILENAME_FORMAT, Locale.US ).format(System.currentTimeMillis()) + ".jpg") // Create output options object which contains file + metadata val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() //シャッター音追加のためのコード val sound = MediaActionSound() sound.load(MediaActionSound.SHUTTER_CLICK) // Setup image capture listener which is triggered after photo has // been taken imageCapture.takePicture( outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { override fun onError(exc: ImageCaptureException) { Log.e(TAG, "Photo capture failed: ${exc.message}", exc) } override fun onImageSaved(output: ImageCapture.OutputFileResults) { val savedUri = Uri.fromFile(photoFile) val msg = "Photo capture succeeded: $savedUri" Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show() Log.d(TAG, msg) //シャッター音の追加 sound.play(MediaActionSound.SHUTTER_CLICK) } }) } private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { ContextCompat.checkSelfPermission( baseContext, it) == PackageManager.PERMISSION_GRANTED } private fun getOutputDirectory(): File { val mediaDir = externalMediaDirs.firstOrNull()?.let { File(it, resources.getString(R.string.app_name)).apply { mkdirs() } } val mediaDirS = mediaDir.toString() Log.d(TAG, "mediaDir: $mediaDirS filesDir:$filesDir" ) return if (mediaDir != null && mediaDir.exists()) mediaDir else filesDir } companion object { private const val TAG = "CameraXBasic" private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" private const val REQUEST_CODE_PERMISSIONS = 10 private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA) } //画像分析のためのクラス private class LuminosityAnalyzer(private val listener: LumaListener) : ImageAnalysis.Analyzer { private fun ByteBuffer.toByteArray(): ByteArray { rewind() // Rewind the buffer to zero val data = ByteArray(remaining()) get(data) // Copy the buffer into a byte array return data // Return the byte array } override fun analyze(image: ImageProxy) { val buffer = image.planes[0].buffer val data = buffer.toByteArray() val pixels = data.map { it.toInt() and 0xFF } val luma = pixels.average() listener(luma) image.close() } } }まとめ
ざっと試した見ましたが、まぁ簡単ですね。
いろいろ情報収集してみると昔のandroidのカメラアプリの作り方ではここまでシンプルにはならなかったようです。
今回は端折ってしまいましたが、cameraXの特徴のひとつでもある画像分析の処理なんかも使って、結構おもしろい事できそうです。
あとは、ARとか画像の加工とかもそのうちチャレンジしてみたいですね。
(cameraX流行ってくれないかなー)
- 投稿日:2020-09-14T23:20:28+09:00
[Flutter]一番簡単にパッケージ名を変更する方法[Android]
方法
Change App Package Name for Flutterを使用する。
まず、"pubspec.yaml"に"change_app_package_name"を追加。
pubspec.yamldependencies: change_app_package_name: ^0.1.2pub getを行い、dependenciesをアップデートする。
flutter pub get以下のコマンドを実行。
( [com.new.package.name]に新しいパッケージ名を入れる。)flutter pub run change_app_package_name:main com.new.package.name以下のファイルにおいて、変更がいい感じに自動で行われます。
- AndroidManifest.xml (release, debug & profile用)
- build.gradleファイル
- MainActivityファイル (java と kotlinの両方対応)
- MainActivityファイルを新しいディレクトリに移動
- 古いディレクトリを削除
パッケージ名変更が確認出来たら、dependenciesから
change_app_package_name: ^0.1.2
を消去して大丈夫です。
- 投稿日:2020-09-14T23:15:51+09:00
ESP32をGoogle Homeデバイスにする
GoogleのスマートスピーカであるGoogle Home Miniに「OK Google、スイッチをオンにして」というと、M5StickCのLEDが点灯するようにします。(要は、Lチカです)
いまさら感はあるのですが、なんでも最新のAndroid 11になって、電源長押しで、Google Homeデバイスを手軽に操作できるようになったのです。
Androidスマホから、電源長押しでこんな感じの画面がすぐ出せるので、いろいろ使えそうです。ソースコードをGitHubに上げておきました。
poruruba/GoogleHomeDevice
https://github.com/poruruba/GoogleHomeDevice構成
まずは、一般ユーザがM5StickCを使うときの構成です。
自宅のGoogle Home Miniスピーカに、「OK Google スイッチをオンにして」と言うと、今回立ち上げるNode.jsサーバが呼び出され、その中でM5StickCと通信して、M5StickCについているLEDを点灯させます。
M5StickCがGoogleHomeデバイスとして認識されるように、Node.jsサーバがActions on Googleに登録しているためです。今回、M5StickCをGoogleHomeデバイスのスイッチとして認識させます。
同様に、手持ちのAndroidスマホからGoogle Homeアプリを立ち上げ、スイッチを選択して、OnさせたりOffさせたりすることもできます。さらに、Android 11であれば、電源長押しで表示される画面からも操作できます。<準備>
上記の動作となるためには、あらかじめNode.jsサーバがGoogle Homeデバイスを扱えるサーバであることをActions on Google登録する必要があります。これは、GoogleHomeデバイス管理会社としての作業です。一方、ユーザの方です。
Google HomeとGoogle Home Miniスピーカは、すでにGoogle Homeアプリを使って、Googleアカウントとつながっているのではないでしょうか。そして、Google HomeとGoogle Home Miniスピーカとnode.jsサーバを紐づければ、すべてがつながります。
これらは、Google Home Miniを所有している一般ユーザの作業です。一般ユーザが、自身が持っているGoogle Home Miniに、GoogleHomeデバイス管理会社を登録することになります。ちょっとわかりにくいかもしれませんが、順を追って説明します。
必要なもの
<一般ユーザとして>
・Google Home Miniスマートスピーカ
・Google Homeアプリ(スマホ)
・Googleアカウント<GoogleHomeデバイス管理会社として>
・Node.jsサーバとそれが動くハードウェア
・LED付きESP32
・GoogleアカウントGoogleアカウントとして、一般ユーザのものとGoogleHomeデバイス管理会社としてのものの2つがあります。
今回は、開発用に作成し、一般には公開しないため、同一アカウントである必要があります。参考となるサンプルコード
以下に、参考となるサンプルコードがあります。
Codelabs
https://developers.google.com/assistant/smarthome/codelabs?hl=jaこのうち、Smart Home Washerがわかりやすく、これをベースに進めていきます。
が、Firebaseを使っていて、何が必須かわけわからなくなりそうなので、Firebaseを使わない方法で進めます。〇Googleアカウントのアクティビティの確認
Googleアカウントは必須なのですが、以下のアクティビティが有効となっている必要があるそうです。
アクティビティの管理
https://myaccount.google.com/activitycontrols・Web & App Activity
・Device Information
・Voice & Audio Activity〇GoogleHomeデバイス管理会社としてプロジェクトを作成する
Actions on Google Developer Consoleより、プロジェクトを作成します。
Actions on Google Developer Console
http://console.actions.google.com/適当なプロジェクト名を入力し、言語をJapanese、国をJapanにします。例えば、MySmartHomeとか。
次に、アクションの種類を選ぶのですが、Smart Homeを選択します。次に、OverviewのQuick setupのName your Smart Home actionを選択し、適当なDisplay nameを入力します。例えば、マイスマートホームとか。
次に、Developタブを選択し、左側のナビゲーションから、Account linkingを選択します。
ここがちょっとわかりにくいかもしれません。OpenID Connectの設定なのですが、今回はCognitoを使います。Google HomeとNode.jsサーバをつなぐときに使います。
Cognitoのユーザプールを作成し、アプリクライアントを作成し、そのアプリクライアントIDとアプリクライアントのシークレットをそれぞれ入力します。手抜きですみませんが、詳細はこちらが参考になるかと思います。
AWS CognitoにGoogleとYahooとLINEアカウントを連携させるAuthorization URLは、以下のようになります。
https://[ドメイン名].auth.ap-northeast-1.amazoncognito.com/oauth2/authorizeToken URLは以下のようになります。
https://[ドメイン名].auth.ap-northeast-1.amazoncognito.com/oauth2/tokenscopeを指定したい場合は、Configure your client (optional)を選択すると、scopeを入力できます。
アプリクライアントの設定において、コールバックURLとして以下を追加しておきます。これはAWS Cognito側の作業です。
https://oauth-redirect.googleusercontent.com/r/[プロジェクトID]プロジェクト名は、Actions on Googleのプロジェクト名で、右上のメニューアイコンから、Project settingsを選択すると表示されるProject IDです。
次に、同じくDevelopタブで、左側のナビゲーションからActionsを選択します。
Fulfillment URLにはこれから立ち上げるサーバのURLを入力します。HTTPSである必要があります。https://【Node.jsサーバのホスト名】/smarthome
以上で、GoogleHomeデバイスを管理するサーバの設定が完了しました。
GoogleHomeAPIの有効化
さきに、GoogleHomeデバイス管理会社は、GoogleHomeと連携するためにGoogleHome APIを実行できるようにしておく必要があります。
GoogleHome API
https://console.cloud.google.com/apis/library/homegraph.googleapis.comここで、「有効にする」ボタンを押下します。
(絵ではすでに有効化されていますが)次に、Node.jsサーバからHomeGraphAPIを呼び出せるように、サービスアカウントキーを作成します。
プロジェクトの認証情報のページに行きます。APIとサービス:認証情報
https://console.cloud.google.com/apis/credentials上の方にある「+認証情報の作成」をクリックし、「サービスアカウント」を選択します。
適当なサービスアカウント名を入力し、「作成」ボタンを押下します。例えば、smarthomeとか。ここで、ロールとして、「Service Accounts」の「サービスアカウント トークン作成者」を選択します。「続行」ボタンを押下します。
「完了」ボタンを押下します。
最初の画面に戻って、もう一度今作成したサービスアカウントを選択します。「鍵を追加」から「新しい鍵を作成」を選択します。
キーのタイプとしてJSONを選択します。ファイルが生成されますので、ローカルPCにダウンロードしておきます。
〇Node.jsサーバの立ち上げ
それでは、GoogleHomeデバイスを管理するNode.jsサーバを立ち上げます。
Googleが便利なnpmモジュールを提供してくれていますので、それを使います。
actions-on-google/actions-on-google-nodejs
https://github.com/actions-on-google/actions-on-google-nodejsNode.jsのexpressを使っているのであれば、すぐにつなげることができます。
こんな感じだそうです。const express = require('express') const bodyParser = require('body-parser') // ... app code here const expressApp = express().use(bodyParser.json()) expressApp.post('/fulfillment', app) expressApp.listen(3000)原理がわかったところで、私がいつも使っているswagger-nodeを使います。
内部のフレームワークとしてexpressを選択すればつながります。
具体的には、以下のページで示している、私がいつも使っているものを使って説明します。(GitHub)https://github.com/poruruba/swagger_template
(参考)SwaggerでLambdaのデバッグ環境を作る(1)具体的には、GitHubサイトを開いて、CodeをZIPダウンロードします。
どこかに展開します。
まずは、以下でnpmモジュールを準備します。
また、さきほどのActions on Googleのnpmモジュールを使うので以下を実行します。npm install -g swagger-node npm install npm install actions-on-googleapi/swagger/swagger.yamlに以下を追加します。path: のところです。
api/swagger/swagger.yaml/smarthome: post: x-swagger-router-controller: routing operationId: smarthome parameters: - in: body name: body schema: $ref: "#/definitions/CommonRequest" responses: 200: description: Success schema: $ref: "#/definitions/CommonResponse" /reportstate: post: x-swagger-router-controller: routing operationId: smarthome_reportstate parameters: - in: body name: body schema: $ref: "#/definitions/CommonRequest" responses: 200: description: Success schema: $ref: "#/definitions/CommonResponse"そして、api/controllers/functions.jsのfunc_tableとexpress_tableのところに、以下のように追記します。
api/controllers/functions.jsconst func_table = { // "test-func" : require('./test_func').handler, // "test-dialogflow" : require('./test_dialogflow').fulfillment, "smarthome_reportstate" : require('./smarthome').handler, }; ・・・ const express_table = { // "test-express": require('./test-express').handler, "smarthome": require('./smarthome').fulfillment, };次に、api/controllers/smarthomeフォルダを作成します。
そこに、keysフォルダを作成し、さきほどダウンロードしたサービスアカウントキーのJSONファイルを置きます。
さらに、以下のindex.jsを作成します。api/controllers/smarthome/index.js'use strict'; const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/'; const Response = require(HELPER_BASE + 'response'); const JWT_FILE_PATH = process.env.JWT_FILE_PATH || '【サービスアカウントキーファイル名】'; const DEVICE_ADDRESS = '【ESP32のIPアドレス】'; const DEVICE_PORT = 3333; // UDP受信するポート番号 const dgram = require('dgram'); const udp = dgram.createSocket('udp4'); const jwt_decode = require('jwt-decode'); const {smarthome} = require('actions-on-google'); const jwt = require(JWT_FILE_PATH); const app = smarthome({ jwt: jwt }); var states_switch = { on: false }; var requestId = 0; const DEFAULT_USER_ID = process.env.DEFAULT_USER_ID || "user01"; var agentUserId = DEFAULT_USER_ID; executeDevice('query'); app.onSync((body, headers) => { console.info('onSync'); console.log('onSync body', body); var decoded = jwt_decode(headers.authorization); console.log(decoded); var result = { requestId: body.requestId, payload: { agentUserId: agentUserId, devices: [ { id: 'switch', type: 'action.devices.types.SWITCH', traits: [ 'action.devices.traits.OnOff', ], name: { defaultNames: ['MyHome Switch'], name: 'スイッチ', }, deviceInfo: { manufacturer: 'MyHome Devices', }, willReportState: true, }, ], }, }; executeDevice('query'); console.log("onSync result", result); return result; }); app.onQuery(async (body, headers) => { console.info('onQuery'); console.log('onQuery body', body); var decoded = jwt_decode(headers.authorization); console.log(decoded); const {requestId} = body; const payload = { devices: {} }; for( var i = 0 ; i < body.inputs.length ; i++ ){ if( body.inputs[i].intent == 'action.devices.QUERY' ){ for( var j = 0 ; j < body.inputs[i].payload.devices.length ; j++ ){ var device = body.inputs[i].payload.devices[j]; if( device.id == 'switch' ){ payload.devices.switch = { on: states_switch.on, online: true, status: "SUCCESS" }; }else { console.log('not supported'); } } } } var result = { requestId: requestId, payload: payload, }; console.log("onQuery result", result); return result; }); app.onExecute(async (body, headers) => { console.info('onExecute'); console.log('onExecute body', body); var decoded = jwt_decode(headers.authorization); console.log(decoded); const {requestId} = body; // Execution results are grouped by status var ret = { requestId: requestId, payload: { commands: [], }, }; for( var i = 0 ; i < body.inputs.length ; i++ ){ if( body.inputs[i].intent == "action.devices.EXECUTE" ){ for( var j = 0 ; j < body.inputs[i].payload.commands.length ; j++ ){ var result = { ids:[], status: 'SUCCESS', }; ret.payload.commands.push(result); var devices = body.inputs[i].payload.commands[j].devices; var execution = body.inputs[i].payload.commands[j].execution; for( var k = 0 ; k < execution.length ; k++ ){ if( execution[k].command == "action.devices.commands.OnOff" ){ for( var l = 0 ; l < devices.length ; l++ ){ if( devices[l].id == "switch"){ result.ids.push(devices[l].id); states_switch.on = execution[k].params.on; await executeDevice(devices[l].id); await reportState(devices[l].id); } } } } } } } console.log("onExecute result", ret); return ret; }); app.onDisconnect((body, headers) => { console.info('onDisconnect'); console.log('body', body); var decoded = jwt_decode(headers.authorization); console.log(decoded); // Return empty response return {}; }); exports.fulfillment = app; async function executeDevice(id){ var message; if( id == 'switch' ){ message = { id: id, onoff: states_switch.on, }; }else if( id == 'query' ){ message = { id: 'query' }; }else{ throw 'unknown id'; } var data = Buffer.from(JSON.stringify(message)); return new Promise((resolve, reject) =>{ udp.send(data, 0, data.length, DEVICE_PORT, DEVICE_ADDRESS, (error, bytes) =>{ if( error ){ console.error(error); return reject(error); } resolve(bytes); }); }); } async function reportState(id){ var state; if( id == 'switch'){ state = { requestId: String(++requestId), agentUserId: agentUserId, payload: { devices: { states:{ [id]: { on: states_switch.on } } } } }; }else{ throw 'unknown id'; } console.log("reportstate", state); await app.reportState(state); return state; } exports.handler = async (event, context, callback) => { var body = JSON.parse(event.body); console.log(body); if( event.path == '/reportstate'){ try{ if( body.id == 'switch'){ states_switch.on = body.onoff; } var res = await reportState(body.id); console.log(res); return new Response({ message: 'OK' }); }catch(error){ console.error(error); var response = new Response(); response.set_error(error); return response; } } };環境に合わせて以下の部分を修正します。
【ESP32のIPアドレス】
【サービスアカウントキーファイル名】
※サービスアカウントキーファイルは、keysフォルダに置いたのであれば、「./keys/***-**.json」という感じになります。また、HTTPSで立ち上げる必要があるため、フロントにHTTPSのサーバを立ち上げてProxyしてもらうか、certフォルダを作成してそこにSSL証明書を配置して、app.jsを書き換えることでHTTPSとして立ち上がります。
以下の辺りです。app.jsvar https = require('https'); try{ var options = { key: fs.readFileSync('./cert/privkey.pem'), cert: fs.readFileSync('./cert/cert.pem'), ca: fs.readFileSync('./cert/chain.pem') };ポート番号を変えたい場合は、.envファイルを作成して、以下のように指定してください。
PORT=10080 SPORT=10443以下のようにして立ち上げます。
> node app.jsNode.jsサーバのソースコード解説
Node.jsサーバには、実装するべきIntentが複数あります。
Intent fulfillment
https://developers.google.com/assistant/smarthome/develop/process-intents
- SYNC:Node.jsサーバが管理するGoogle Homeデバイスの情報を返します。複数のデバイスを返すことができます。ユーザがNode.jsサーバが管理するGoogle Homeデバイスを利用登録すると呼ばれます。
- QUERY:Node.jsサーバが管理するGoogle Homeデバイスの状態を返します。ユーザがGoogle Homeアプリを使ってGoogle Homeデバイスを表示されている間定期的に状態を得るためにQUERYが呼ばれてきます。
- EXECUTE:Node.jsサーバが管理するGoogle Homeデバイスに対する変更要求です。Google Home Miniスピーカから、「OK Google、スイッチをオンにして」と言われて、Node.jsが管理するGoogle Homeデバイスの状態の変更要求が来た時に呼ばれます。また、AndroidのGoogle Homeアプリから、Google Homeデバイスを操作したときにも呼ばれます。
- DISCONNECT:Google Homeデバイスがユーザから管理対象から外されたときに呼ばれます。
具体的な入出力電文のJSONフォーマットは以下を参照してください。
- https://developers.google.com/assistant/smarthome/reference/intent/sync
- https://developers.google.com/assistant/smarthome/reference/intent/query
- https://developers.google.com/assistant/smarthome/reference/intent/execute
- https://developers.google.com/assistant/smarthome/reference/intent/disconnect
受信時に呼ばれる関数は、それぞれ以下が対応します。
- app.onSync(function(body, headers));
- app.onQuery(function(body, headers));
- app.onExecute(function(body, headers));
- app.onDisconnect(function(body, headers));
外部から受け付けるエンドポイントは「/smarthome」としており、それを、functions.jsで指定したフォルダに転送し、
exports.fulfillment = app;として受け取っています。
SYNC IntentでGoogle Homeデバイスの定義
Google Homeデバイスの定義は、SYNCに対する応答として返しています。
まず決めるのがTypeです。
Typeは、デバイスの種類を示します。機能は後ほど示すTraitsであり、それらを束ねるものと思ってもよいです。
例えば、エアコンとか、洗濯機とか、照明とか。Smart Home Device Types
https://developers.google.com/assistant/smarthome/guides今回は、単純にLEDの点灯だけなので、
action.devices.types.SWITCH
を選択しました。Smart Home Switch Guide
https://developers.google.com/assistant/smarthome/guides/switch次が、Traitsです。
GoogleHomeデバイスが持っている機能です。Smart Home Device Traits
https://developers.google.com/assistant/smarthome/traits今回は、点灯と消灯の2種類なので、action.devices.traits.OnOff をもっていることとしました。ちなみに、このTraitsは電源のOn/Offとして、いろんなデバイスで共通でもっている機能(Traits)です。
Smart Home OnOff Trait Schema
https://developers.google.com/assistant/smarthome/traits/onoff上記のページに、SYNCの応答として、どのようなAttributesを返すべきかなどが記されています。
ちなみに、その他SYNC Intentで共通で返すべき情報は以下に記載されています。
action.devices.SYNC
https://developers.google.com/assistant/smarthome/reference/intent/sync以下がその部分の抜粋です。
index.jsvar result = { requestId: body.requestId, payload: { agentUserId: agentUserId, devices: [ { id: 'switch', type: 'action.devices.types.SWITCH', traits: [ 'action.devices.traits.OnOff', ], name: { defaultNames: ['MyHome Switch'], name: 'スイッチ', }, deviceInfo: { manufacturer: 'MyHome Devices', }, willReportState: true, }, ], }, };agentUserIdは、接続してきたユーザのIdを指定します。本来であれば、ユニークなIDとしてユーザを区別するべきなのですが、自分しか使わないので固定にしています。
たとえば、headersに、OpenID Connectで認証したユーザのアクセストークンが入っていますので、例えばトークンの中のnameをそれに使うのがよいかと思います。index.jsvar decoded = jwt_decode(headers.authorization); console.log(decoded);QUERY IntentでGoogle Homeデバイスの状態を返す
M5StickCのLEDの点灯状態を返します。
とはいっても、M5StickCとどうやって通信するかというと、今回はUDPを使いました。今回の実装では、QUERY Intentが来てからGoogle HomeデバイスのM5StickCに問い合わせるのではなく、LEDの点灯状態を変更したタイミングあるいは変更されたタイミングでUDPパケットを受け取るようにしておき、QUERY Intentが来たら覚えておいた状態を返すようにしています。
以下が、M5StickCから状態を取得する部分の抜粋です。
index.jsexports.handler = async (event, context, callback) => { var body = JSON.parse(event.body); console.log(body); if( event.path == '/reportstate'){ try{ if( body.id == 'switch'){ states_switch.on = body.onoff; } var res = await reportState(body.id); console.log(res); return new Response({ message: 'OK' }); }catch(error){ console.error(error); var response = new Response(); response.set_error(error); return response; } } };外部から受け取るエンドポイントは、「/reportstate」で、functions.jsで指定されたフォルダに転送して受け取っています。
M5StickC→Node.jsの方向の通信です。一方、Node.jsからLED点灯したり状態取得を要求したりするNode.js→M5StickC方向の通信として以下の関数を作成しています。UDP送信です。index.jsasync function executeDevice(id){ var message; if( id == 'switch' ){ message = { id: id, onoff: states_switch.on, }; }else if( id == 'query' ){ message = { id: 'query' }; }else{ throw 'unknown id'; } var data = Buffer.from(JSON.stringify(message)); return new Promise((resolve, reject) =>{ udp.send(data, 0, data.length, DEVICE_PORT, DEVICE_ADDRESS, (error, bytes) =>{ if( error ){ console.error(error); return reject(error); } resolve(bytes); }); }); }idとして"switch"を指定すると、M5StickCのLEDを点灯させたり消灯させたりします。一方で、"query"を指定すると、今のM5StickCのLEDの状態の取得を要求します。その応答が、さきほどの、/reqportstateのエンドポイントです。実はこの受け口はHTTP Postでして、別途もう一つ立ち上げるNode.jsサーバ(UDP)で、UDP受信・HTTP Post送信をして仲介しています。
(なぜ、UDPにこだわるかというと、Google Homeデバイスには、Local Fulfillmentという機能があるそうで、UDPが対応しているためです。次回頑張ろうと思います)
このエンドポイントには2つの意味があります。
1つ目は、先ほどのお伝えした通り、今のLEDの状態を取得するためのものです。
もう一つは、M5StickCのボタン押下でLEDを変更したときに状態変化通知を取得するためのものです。今回、OK GoogleやGoogleHomeアプリからのLED点灯・消灯に加えて、M5StickC本体でもボタンの押下で点灯・消灯を切り替え、その状態をGoogle Homeアプリに反映するようにしました。
EXECUTE IntentでGoogle Homeデバイスの状態を変更
「OK Google、スイッチをオンにして」、と変更のリクエストを受け取るのがこのEXECUTEです。
抜粋しておきます。
index.jsapp.onExecute(async (body, headers) => { console.info('onExecute'); console.log('onExecute body', body); var decoded = jwt_decode(headers.authorization); console.log(decoded); const {requestId} = body; // Execution results are grouped by status var ret = { requestId: requestId, payload: { commands: [], }, }; for( var i = 0 ; i < body.inputs.length ; i++ ){ if( body.inputs[i].intent == "action.devices.EXECUTE" ){ for( var j = 0 ; j < body.inputs[i].payload.commands.length ; j++ ){ var result = { ids:[], status: 'SUCCESS', }; ret.payload.commands.push(result); var devices = body.inputs[i].payload.commands[j].devices; var execution = body.inputs[i].payload.commands[j].execution; for( var k = 0 ; k < execution.length ; k++ ){ if( execution[k].command == "action.devices.commands.OnOff" ){ for( var l = 0 ; l < devices.length ; l++ ){ if( devices[l].id == "switch"){ result.ids.push(devices[l].id); states_switch.on = execution[k].params.on; await executeDevice(devices[l].id); await reportState(devices[l].id); } } } } } } } console.log("onExecute result", ret); return ret; });さきほどお伝えした、executeDevice()を呼び出しているのがわかります。
ここで、関数reportState()も呼んでいます。実はさっきの/reportstateでも出てきていました。index.jsasync function reportState(id){ var state; if( id == 'switch'){ state = { requestId: String(++requestId), agentUserId: agentUserId, payload: { devices: { states:{ [id]: { on: states_switch.on } } } } }; }else{ throw 'unknown id'; } console.log("reportstate", state); await app.reportState(state); return state; }これは、Google Homeに状態が変わったことを伝えるためのものです。
直接Google Homeデバイスを操作して、M5StickCのLED状態を変えたときには、この関数を呼び出して、Google Homeに新しい状態を伝える必要があります。ESP32からのUDP受信を待ち受けるNode.jsサーバ(UDP)
npmモジュールのnode-fetchを使っています。
index.js'use strict'; var dgram = require('dgram'); const { URL, URLSearchParams } = require('url'); const fetch = require('node-fetch'); const Headers = fetch.Headers; const base_url = "【Node.jsサーバのURL】"; var UDP_HOST = '【自身のIPアドレス】'; var UDP_PORT = 3333; //ESP32からのUDP受信を待ち受けるポート番号 var server = dgram.createSocket('udp4'); server.on('listening', function () { var address = server.address(); console.log('UDP Server listening on ' + address.address + ":" + address.port); }); server.on('message', async (message, remote) => { console.log(remote.address + ':' + remote.port +' - ' + message); var body = JSON.parse(message); var json = await do_post(base_url + '/reportstate', body); console.log(json); }); server.bind(UDP_PORT, UDP_HOST); function do_post(url, body) { const headers = new Headers({ "Content-Type": "application/json; charset=utf-8" }); return fetch(new URL(url).toString(), { method: 'POST', body: JSON.stringify(body), headers: headers }) .then((response) => { if (!response.ok) throw 'status is not 200'; return response.json(); }); }以下の部分を環境に合わせて変更してください。
【Node.jsサーバのURL】
【自身のIPアドレス】以下のようにして立ち上げます。
> node index.jsESP32のソースコード
最後に、GoogleHomeデバイスであるM5StickCのソースコードです。
いきなりですが、こんな感じです。main.cpp#include <M5StickC.h> #include <WiFi.h> #include <ArduinoJson.h> const char* wifi_ssid = "【WiFiアクセスポイントのSSID】"; const char* wifi_password = "【WiFiアクセスポイントのパスワード】"; const char *udp_report_host = "【Node.jsサーバ(UDP)のIPアドレス】"; #define UDP_REQUEST_PORT 3333 //Node.jsサーバからのUDP受信を待ち受けるポート番号 #define UDP_REPORT_PORT 3333 //Node.jsサーバ(UDP)へUDP送信する先のポート番号 #define LED_PIN GPIO_NUM_10 const int capacity_request = JSON_OBJECT_SIZE(3); const int capacity_report = JSON_OBJECT_SIZE(3); StaticJsonDocument<capacity_request> json_request; StaticJsonDocument<capacity_report> json_report; #define BUFFER_SIZE 255 char buffer_request[BUFFER_SIZE]; char buffer_report[BUFFER_SIZE]; bool led_status = false; bool isPressed = false; WiFiUDP udp; void wifi_connect(void){ Serial.println(""); Serial.print("WiFi Connenting"); WiFi.begin(wifi_ssid, wifi_password); while (WiFi.status() != WL_CONNECTED) { Serial.print("."); delay(1000); } Serial.println(""); Serial.print("Connected : "); Serial.println(WiFi.localIP()); M5.Lcd.println(WiFi.localIP()); } void setup() { M5.begin(); M5.Lcd.setRotation(3); M5.Lcd.fillScreen(BLACK); M5.Lcd.setTextColor(WHITE, BLACK); M5.Lcd.println("[M5StickC]"); Serial.begin(9600); Serial.println("setup"); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, HIGH); wifi_connect(); Serial.println("server stated"); udp.begin(UDP_REQUEST_PORT); } void reportState(){ json_report.clear(); json_report["id"] = "switch"; json_report["onoff"] = led_status; serializeJson(json_report, buffer_report, sizeof(buffer_report)); udp.beginPacket(udp_report_host, UDP_REPORT_PORT); udp.write((uint8_t*)buffer_report, strlen(buffer_report)); udp.endPacket(); } void loop() { M5.update(); int packetSize = udp.parsePacket(); if( packetSize > 0){ Serial.println("UDP received"); int len = udp.read(buffer_request, packetSize); DeserializationError err = deserializeJson(json_request, buffer_request, len); if( err ){ Serial.println("Deserialize error"); Serial.println(err.c_str()); return; } const char* id = json_request["id"]; if( strcmp(id, "query") == 0 ){ reportState(); }else if( strcmp(id, "switch") == 0 ){ led_status = json_request["onoff"]; digitalWrite(LED_PIN, led_status ? LOW : HIGH); } } if( M5.BtnA.isPressed() ){ if( !isPressed ){ isPressed = true; Serial.println("BtnA.Released"); led_status = !led_status; digitalWrite(LED_PIN, led_status ? LOW : HIGH); reportState(); delay(100); } }else if( M5.BtnA.isReleased() ){ isPressed = false; } delay(10); }以下の部分は環境に合わせて変更してください。
【WiFiアクセスポイントのSSID】
【WiFiアクセスポイントのパスワード】
【Node.jsサーバ(UDP)のIPアドレス】UDP受信したら、その内容をJSONパースして、idがswitchだったらLEDを点灯したり、消灯したりし、queryだったら状態をUDPで返しています。
また、ボタンの押下を検出したら、JSON文字列化して、状態をUDP送信します。
JSONパースおよび文字列化には、ArduinoJsonを利用しています。使ってみる
それではさっそく、一般ユーザとして、使ってみましょう。
さきほどのNode.jsサーバやNode.jsサーバ(UDP)を立ち上げておきましょう。まずは、AndroidからGoogle Homeアプリを立ち上げます。
Google Home MiniスマートスピーカはすでにGoogle Homeアプリで登録されている前提です。左上の「+」ボタンを押下し、次に、「デバイスのセットアップ」を選択します。
さらに、「Googleと連携させる」を選択します。
そうすると、[test]と接頭辞が付いたものが見つかります。例:[test]マイスマートホーム。
さっそくそれを選択します。そうすると、ログイン画面が表示されます。
これは、Actions on GoogleのAccount Linkingで設定したauthorizeエンドポイントが呼び出された結果です。OpenID ConnectとしてCognitoを使ったのでCognitoのログイン画面が出ています。Cognitoの設定内容によって見え方は変わります。アカウントログインが完了すると、
めでたく、以下のようなGoogle Homeデバイス選択画面が現れます。
選択して、部屋に追加しましょう。
最後に完了ボタンを押すと、以下のように登録されます。さっそく、オンにする をタップしてみましょう。
M5StickCのLEDがOnになり、画面上も緑色が付いたかと思います。Offもできます。
次は、音声で。「OK Google、スイッチをオンにして」と話してみましょう。LEDがOnになり、画面上も変わりましたでしょうか。
最後に、Android 11だけですが、電源ボタンを長押しします。
メニューボタンからコントロールを追加を選択します。
そこでスイッチのチェックボックスをOn状態にして、「保存」ボタンを押下します。これで、ワンタッチで、M5StickCのLEDを点灯したり消灯できるようになりました!
最後に
以前、Alexaのスマートホームで、黒豆の学習リモコンを制御しました。今度はこれをGoogleHomeデバイス対応しようと思います。
スマートホームスキルを作る(1):黒豆を操作するRESTful API環境を構築するLocal Fulfillmentというのがあって、GoogleHomeでJavascriptを動かして直接GoogleHomeデバイスを制御するとか。今度調べてみようと思います。
https://developers.google.com/assistant/smarthome/concepts/localこちらを参考にさせていただきました。ありがとうございました。
"○○のアプリにつないで"不要の Google Home 対応スマートホームアプリの実装以上
- 投稿日:2020-09-14T20:57:03+09:00
iPhoneとAndroidの比較
長らくandroidを使っていましたがiPhoneについて聞かれることも多く、食わず嫌いも好きではないので廉価版の販売されているこのタイミングで使うことにしました。
主義主張
- 何事も一強状態ではサービスの向上は望めない
- プロプライエタリな環境のみに依存するべきではない
- LINEは入れない(信用できない)
- なんで各キャリアはzenfoneやpixel4aを販売しないのか…(残念)
使用前のiPhoneのイメージからの2週間後
- 個人情報の取扱はgoogleよりはマシ → どっちもどっち(appleだけは全収集、パートナー企業にはなにも渡さない。)
- 入力も工夫されていて使い易い → そうでもない。(ATOKが別途購入なのはつらい)
- バッテリー持ちがよい → 各アプリに問題がなければ間違いなくバッテリー持ちは秀逸
- 表示が速い → 多くの場合においてはそのとおり
- 3ヶ月後には「iPhone最高」と言ってる可能性 → 今すぐにでもzenfone7を買いたい!
- アクセサリが豊富 → かなり安価で良品が多い、ある意味今回iPhoneにして一番楽しいところかもしれない。
- 壊れ安い → 持っただけで割れ物感がハンパない。androidでは一度も落として壊れたことがないが、本気で保証に入る必要があると感じる。
- システムが安定している → たまにフリーズするし、androidとさしてかわらない。
考察
- windowsとmacにおいても同じだが、appleは組立を自社のみで行うことでバグフィックスを容易にしているためそこにメリットがある。ただし、シェアが拡大している現状においては、懸念すべき点も増えている。ユーザーを甘やかし「iPhoneしか使えない」状態にしてからの値上げ、個別の課金など近年においてはシェアを低下させるほどである。
- カスタマイズ性については、限定的であり様々なニーズに対応できるとは言えない。
- 撮影した画像のサイズ表示も手間がかかるようになっていて、Cloud購入しないとこれ以上保存できないような表示が頻繁にでるなど営利性がひどくなっている。
- 最初のandroidからの移行アプリがあると書いてあったので、使ったのが間違い、、、googleサーバー、端末のローカルにあるデータすべて根こそぎもっていき、「iCloudを購入してください^^」と初っぱなにでる始末。結論移行時あのアプリを使うのはおすすめしない。。
- 海外のiPhoneのシェアが3割に満たないのに対して日本は7割近くなっている。日本人のITリテラシーや機械音痴の異常さが、appleの収益を加速させている気がする。。
結論
- 今すぐお家に帰りたい><
- でも2年は使う縛り^^
- 合い言葉は「使ってからディスる!」
- 投稿日:2020-09-14T19:34:15+09:00
AndroidのKotlinで電卓作成 デザイン編
xmlで電卓の位置調整が難しいので、調べてみた。
<LinearLayout
android:orientation="vertical">で縦方向を指定し、
<LinearLayout
android:orientation="horizontal">で横方向を入れ子にして書いていくことで解決した。
activity_main.xml<LinearLayout android:id="@+id/linearLayout_width" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_margin="5dp" app:layout_constraintVertical_chainStyle="packed" android:orientation="vertical"> <LinearLayout android:id="@+id/linearLayout1" android:layout_width="match_parent" android:layout_height="0pt" android:layout_weight="1" android:layout_margin="10dp" android:orientation="horizontal"> <TextView android:id="@+id/calc_display" android:layout_width="376dp" android:layout_height="match_parent" android:paddingLeft="5pt" android:text="0" android:textSize="36sp" app:layout_constraintBottom_toTopOf="@+id/clear" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </LinearLayout> <LinearLayout android:id="@+id/linearLayout3" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:layout_margin="10dp" android:orientation="horizontal"> <Button android:id="@+id/one" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="1" /> <Button android:id="@+id/two" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="2" /> <Button android:id="@+id/three" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="3" /> <Button android:id="@+id/clear" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="C" /> </LinearLayout> <LinearLayout android:id="@+id/linearLayout4" android:layout_width="match_parent" android:layout_height="0pt" android:layout_weight="1" android:layout_margin="10dp" android:orientation="horizontal"> <Button android:id="@+id/four" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="4" /> <Button android:id="@+id/five" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="5" /> <Button android:id="@+id/six" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="6" /> <Button android:id="@+id/minus" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="-" /> </LinearLayout> <LinearLayout android:id="@+id/linearLayout5" android:layout_width="match_parent" android:layout_height="0pt" android:layout_weight="1" android:layout_margin="10dp" android:orientation="horizontal"> <Button android:id="@+id/seven" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="7" /> <Button android:id="@+id/eight" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="8" /> <Button android:id="@+id/nine" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="9" /> <Button android:id="@+id/plus" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="+" /> </LinearLayout> <LinearLayout android:id="@+id/linearLayout6" android:layout_width="match_parent" android:layout_height="0pt" android:layout_weight="1" android:layout_margin="10dp" android:orientation="horizontal"> <Button android:id="@+id/percent" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="%" /> <Button android:id="@+id/zero" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="0" /> <Button android:id="@+id/dot" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="." /> <Button android:id="@+id/equel" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="=" /> </LinearLayout> </LinearLayout>
- 投稿日:2020-09-14T18:24:00+09:00
3. 【Android/Kotlin】画面遷移
はじめに
DreamHanksのMOONです。
前回はアプリの画面にボタンを追加し、そのボタンにイベント機能を追加してみました。
2. 【Android/Kotlin】ボタン追加今回は画面を遷移させる方法と遷移させる場合、値も送る方法を見ていきたいと思います。
Intentとは
Intent は、別のアプリ コンポーネントからのアクションをリクエストするときに使用できるメッセージング オブジェクトです。インテントはコンポーネント間のコミュニケーションをいくつかの方法で円滑化しますが、基本的な使用例はアクティビティを開始、サービスを開始、ブロードキャストを配信です。
詳細な概念については
https://developer.android.com/guide/components/intents-filters#kotlin
このリンクで確認してください。Intentで画面遷移
・遷移される新しいActivityとxmlを追加します。
IntentTestActivity.ktpackage com.example.practiceapplication import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.widget.TextView class IntentTestActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_intent_test) val intent_contents = findViewById<TextView>(R.id.intent_contents) //Intentオブジェクで「main_tv」というkeyに対する値を代入する val intented_string = intent.getStringExtra("main_tv") intent_contents.text = intented_string } }<?xml version="1.0" encoding="utf-8"?> <LinearLayout 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" android:orientation="vertical" tools:context=".IntentTestActivity" android:gravity="center"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/intent_title" android:textSize="40dp" android:text="次の画面です。" android:layout_marginBottom="100dp"/> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/intent_contents" android:textSize="20dp" android:text="ここに以前のテキストが表示" /> </LinearLayout>・MainActivity(遷移させる画面のActivity)ファイルを下記のように修正します。
package com.example.practiceapplication import android.content.Intent import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.widget.Button import android.widget.TextView class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val main_tv = findViewById<TextView>(R.id.main_tv) //画面のテキストヴュー val change_btn = findViewById<Button>(R.id.change_btn) //画面のボタン //ボタンクリックイベントリスナー設定 change_btn.setOnClickListener { //Intentオブジェクト生成、遷移画面定義 val nextIntent = Intent(this, IntentTestActivity::class.java) //Intentオブジェクトにテキストの内容をプットする nextIntent.putExtra("main_tv", main_tv.text.toString()) //次のActivity実行 startActivity(nextIntent) } } }・ボタンのクリックリスナー内にIntentという画面を遷移するためのオブジェクトを生成。
・画面を遷移させる前にputExtraメソッドを使って最初画面のテキストの文字列を入れます。
・最後にstartActivityメソッドにintentオブジェクトを入れて実行します。アプリ起動
終わりに
今回は画面を遷移させる方法と遷移させる場合、値も送る方法を見てみました。
次回は「リストビュー」について見ていきたいと思います。
4. 【Android/Kotlin】リストビュー(ListView)
- 投稿日:2020-09-14T15:24:43+09:00
ArduinoJson でJsonデータとして送信でいなかった
ArduinoJson でJsonデータとして送信でいなかった
ESP32 や M5stack などで通信するときセンサーの複数のデータをわかりやすく送信するのに Json 形式で送信出ればいいと考えました。
ArduinoJson というライブラリがあり、これを使用することで簡単に Json 形式で送信できるようです。
使用してみよう
testJson.ino#include <ArduinoJson.h> char pubMessage[512]; void setup(){ Serial.begin(115200); } void loop(){ String data = buildJson(); data.toCharArray(pubMessage, data.length()); Serial.println(pubMessage); delay(1000); } String buildJson() { String json = ""; const int capacity = JSON_OBJECT_SIZE(20); StaticJsonDocument<capacity> doc; DynamicJsonDocument logs(64); int val1, val2; val1 = 0; doc["Sensing_A"] = val1; val2 = 0; doc["Sensing_B"] = val2; serializeJson(doc, json); return json; }結果確認
{"Sensing_A":0,"Sensing_B":0 {"Sensing_A":0,"Sensing_B":0 {"Sensing_A":0,"Sensing_B":0 {"Sensing_A":0,"Sensing_B":0このデータを送信しても送信先では、Json と認識できないし変換もできません。
コード修正
原因と考えられるのがこのコードです
data.toCharArray(pubMessage, data.length());この部分のデータの長さを示すのが 1 文字足りないと考えられます。なので、このコードを修正します。
data.toCharArray(pubMessage, data.length() + 1);修正結果
{"Sensing_A":0,"Sensing_B":0} {"Sensing_A":0,"Sensing_B":0} {"Sensing_A":0,"Sensing_B":0} {"Sensing_A":0,"Sensing_B":0}これで送信して変換、Json と認識できるようになりました。
- 投稿日:2020-09-14T10:37:17+09:00
Github DependabotでライブラリアップデートPRを自動作成した際の動作確認をなるべく楽にした話
そもそもDependabotとは
GithubにDependabotという機能があるのはご存知でしょうか?
リポジトリ内で使用している依存関係(ライブラリ)で古いものがあれば更新をかけたPullRequestを自動発行してくれる機能です。
パブリックなリポジトリで以下のようなPRを見たことがある方も多いと思います。もともとは独立したサービスでしたが、Githubに買収されてネイティブ機能として取り込まれたので導入がしやすくなりました。
.github/dependabot.yml
配下に設定ファイルを配置するだけでライブラリアップデートPR自動作成を有効に出来ます。今回Dependabot自体の解説は省きますが私の携わるプロジェクトでは以下のような設定ファイルで月曜日の9時にチェックがかかるように運用を始めています。
version: 2 updates: - package-ecosystem: "gradle" directory: "/" target-branch: "develop" schedule: interval: "weekly" day: "monday" time: "09:00" timezone: "Asia/Tokyo" reviewers: - "ignis-ltd/with-android"まずはGithub Dependabotについて詳しく知りたいという方は以下の公式ドキュメントや記事が参考になると思います
※現状「Dependabot」でググると買収される前の独立したサービスだった時代のマーケットプレイス版の記事がたくさん出てきますが、そちらはいずれ廃止されてネイティブ機能に統合される可能性が高いのでご注意下さい
Dependabotの運用課題
Dependabotは1ライブラリのアップデートにつき1つのPRを発行されます。
一部のライブラリアップデートが問題を起こす可能性もあるので問題を切り分けるためにも個別になっている事自体は正しい形だと思っているのですが、いくつもアップデートがある場合やや課題が残ります。サーバーサイドでも似たような課題は発生しうると思いますが、私の扱うプロジェクトではAndroidアプリなので上記のように5つのPRが一度に発行された場合5回ブランチをcheckoutしてビルドしてインストールして動作確認をする必要がありました。
もちろんCI上でテストを組んで自動化すればリスクを減らすことも出来ますが、やはりアプリの場合はUIの崩れ等が心配なのでマージ前に目視での動作確認は1度はしておきたいところです。
あるいはCIでブランチごとにデプロイまで行えばチェックアウトまではしなくても良いですが、それでも5回ダウンロードしてインストールして起動するだけでもちょっと大変ですよね。解決策
そこで考えたアプローチとして、dependabotが発行したそれぞれのPRのブランチを一つのブランチへマージしてCIからビルドを行い、各PRにコメントとしてバイナリへのリンクを残すという手法を検討しました。
以下で実際のやり方をご紹介致します。Dependabotが発行したブランチを統合する
まずアップデートがかかったGitブランチをすべて抽出して統合するRubyスクリプトを組んでみます。
dependabot/
の文字列から始まるブランチ一覧を取得する- 現在のブランチ(develop)からアップデートcommitのパッチファイルを作成する
- 2.で取得したパッチファイルをすべて適用する
(Ruby力は底辺なのでそのあたりは見逃してください
)
Gemfilesource 'https://rubygems.org' gem 'git'merge_dependabot_branchs.rbrequire "git" dependabotBranchs = [] git_client = Git.open(Dir.pwd) git_client.fetch git_client.branches.each do |branch| if branch.name.start_with?('dependabot/') dependabotBranchs.append(branch) end end return if dependabotBranchs.size <= 0 dependabotBranchs.each do |dependabotBranch| system("git format-patch -1 #{dependabotBranch.gcommit} --unified=0") end system("git apply 0001-*.patch --unidiff-zero")ちょっとした解説
ここで肝となるのがパッチファイルの作成と適用方法です。
git format-patch
でパッチファイルを生成していますが、通常では前後3行の変更もDiffに含まれています。
つまりアップデートがかかったライブラリの行が隣り合っているときなど、前後3行以内に変更がすでにある場合コンフリクトとして判断されてしまい自動での統合がされません。
Dependabotによるバージョンアップの変更だけを抽出すればコンフリクトは起こり得ないはずなので、近い行で変更があってもコンフリクトとみなさず統合してほしいものです。
そこで--unified=0
パラメータを付与することで前後の行をDiffに含めないように調整しています。(参考)また、
git apply
でパッチファイルを適用する際にも小技を使っていて、通常では前後の行が含まれていない(--unified=0
を指定した)パッチファイルでは適用が失敗してしまうので--unidiff-zero
パラメータを付与して前後の行を無視して適用されるようにしています。(参考)この辺の処理は当初使っていたruby-gitだと機能不足だったのでsystemコールに頼っています。
ここまででライブラリアップデートが統合されたブランチが出来上がっているはずなので、この状態でビルドすれば統合されたバイナリを生成できます。
Dependabotが発行したPRに統合したバイナリへのリンクを貼る
CI上でバイナリを生成してリンクURLも発行したと仮定して、もともとのDependabotが生成したPRにコメントを残すRubyスクリプトを組んでみます。
今度はGitではなくGithub APIを使って抽出します。
- 現在開かれているPullRequestで
dependabot/
の文字列から始まるブランチを抽出する- 抽出したPullRequestに対してバイナリへのリンクと統合したPullRequest一覧をコメントとして貼る
(Ruby力底辺コード)
comment_dependabot_prs.rubyrequire 'net/http' require 'uri' require 'json' uri = URI.parse("https://api.github.com/repos/ignis-ltd/with_android/pulls") request = Net::HTTP::Get.new(uri) request["Accept"] = "application/vnd.github.v3+json" request["Authorization"] = "token #{ENV['GITHUB_API_TOKEN']}" req_options = { use_ssl: uri.scheme == "https", } response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http| http.request(request) end dependabotPullRequests = [] responseBodyJson = JSON.parse(response.body, symbolize_names: true) responseBodyJson.each do |pull| if pull[:head][:ref].start_with?('dependabot/') dependabotPullRequests.append(pull) end end dependabotPullRequests.each do |pull| uri = URI.parse("https://api.github.com/repos/ignis-ltd/with_android/issues/#{pull[:number]}/comments") request = Net::HTTP::Post.new(uri) request.body = JSON.dump({ "body" => "Deployed a binary that merged the following branches\n" + dependabotPullRequests.map { |pull| pull[:html_url] }.join(" ") + "\n\n#{ENV['INSTALL_PAGE_URL']}" }) request["Accept"] = "application/vnd.github.v3+json" request["Authorization"] = "token #{ENV['GITHUB_API_TOKEN']}" req_options = { use_ssl: uri.scheme == "https", } response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http| http.request(request) end endちょっとした解説
基本的にはGithub APIを使って愚直に実装しているだけです。
上記のRubyスクリプトの実行には以下の環境変数とが必要となります。Github API用のURLも適宜変更して下さい
Githubのトークン情報は直接含めず、CIのシークレット環境変数などを使って下さい。
- GITHUB_API_TOKEN ... GithubへのコメントするためのTokenを指定して下さい
- 個人アクセストークンを使用する - GitHub Docsを参照して取得してください
- INSTALL_PAGE_URL ... 生成したバイナリへのリンクURLを指定して下さい
- 今回はCIでのビルドやデプロイまでは言及しないので省略しますが、アプリのCIは個人的にはBitrise推しです。
ここまで組むことができればDependabotが発行したPullRequestに以下のようなコメントを残すことが出来ます。
どのブランチを統合したのかと統合されたバイナリをどのPullRequestからも参照することが出来るようになります。Dependabotの実行後にCIでスクリプトとビルドを定時実行させる
私のプロジェクトではDependabotの実行を月曜9:00に行っているため、このスクリプトを含んだCIの実行は月曜10:00に行うように調整しています。
以下はBitriseでの設定例です。このあたりはお好みで調整して下さい。補足というか注意点
最初のDependabotのブランチを統合するスクリプトはGitのブランチ情報を基準にしてますが、後のコメントする際のスクリプトはPullRequest基準で検索をかけるので、(基本的にはないと思いますが)PullRequestが発行されていないDependabotブランチが存在する場合や、ビルド中にブランチやPRに変更を加えた場合はコメントの内容とバイナリの実態にズレが生じます。
また、
dependabot/
という文字列でブランチ名を前方一致検索しているので、これに該当するブランチを手動で作った際に誤作動を起こすのでもう少し厳密な判定ロジックを設けるべきかもしれないです(横着感)おわり
結構力技でしたが、ここまでやることで毎週の動作確認が1度で済ませられるのでコスト削減につながるかと思います。
小さなプログラムではそこまでライブラリが多くないと思うのでここまでする必要性も感じないかもしれませんが、10万行を超えてくる規模だと導入されているライブラリの数も膨大になり動作確認コストも馬鹿にできないので、このような対策を講じることでDependabotを最大限有効活用することが出来るようになりました。また、今回DependabotをCI上で統合するお話ですが、スクリプトを書いて気合で統合してなんとかするという部分の話だけでCI側の話がほとんど出来ていないのでどこかでお話できたらと思います。
- 投稿日:2020-09-14T02:57:51+09:00
[Android] オブジェクトをMockする
単体Test時に、ObjectをMockしたい時があったのでメモ。
class MainViewModel : ViewModel() { private val _date = MutableLiveData<String>() val date: LiveData<String> get() = _date fun fetchDate() { val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm", Locale.JAPAN) _date.value = sdf.format(DateObj.getCurrent()) } }上記のViewModelをTestするときに、DataObjをMockしてみる。
@Before fun beforeTests() { mockkObject(DateObj) every { DateObj.getCurrent() } returns Data() }
- 投稿日:2020-09-14T02:30:34+09:00
[Android] Firebase Authentication使ってみた
今月はFirebaseを触りまくるぞってことで、Firebase Authenticationを使用してみた
Firebase設定
公式を参考にプロジェクトの作成とアプリの追加を行った。
1. Firebaseにログインし、プロジェクトを作成します。
2. Android Studioでアプリのプロジェクトを作成
3. 作成したプロジェクトにアプリを追加します。公式参照
4. 実装1、2については詰まることはないと思うので省きます。3で少し躓くかもしれないのでここから説明していきます。
作成したプロジェクト画面のプロジェクト概要で、以下の様なアプリ追加のボタンがありますので、こちらから追加していきます。
追加するアプリはAndroidを選択すると、
こちらの画面が開くと思います。ここでAndroidパッケージ名を入力するため、予めアプリを作成しておいてください。
デバッグ用の著名証明書は省略可能ですが、ログイン認証で必要になってくると思うので、Firebase Authentication
を使用する際は設定しなければなりません(多分)。こちらを参考にコマンドを叩けくだけです。keytool -list -v \ -alias androiddebugkey -keystore ~/.android/debug.keystore
OR
./gradlew signingReport
1つ目のコマンドを叩くとキーストアのパスワードを求められますが、デバック用のキーストアのパスワードは、
android
です。後は、説明通りに進めていけば追加できます(説明がとても丁寧なので省きます)。最後にアプリとの接続を確認するフェーズがあるので、そこで待機しておく(しなくてもいい)。実装
Gralde
gradle.xml// Project dependencies { // ... classpath 'com.google.gms:google-services:4.3.3' } // App apply plugin: 'com.google.gms.google-services' dependencies { // ... // Firebase implementation 'com.google.firebase:firebase-auth-ktx:19.4.0' implementation 'com.google.firebase:firebase-analytics:17.5.0' implementation 'com.google.firebase:firebase-core:17.5.0' }ここで一旦ビルドすると、接続の確認がとれるはず
機能実装
認証状況
FirebaseAuth
をインスタンス化します。class LoginActivity : AppCompatActivity() { private lateinit var auth: FirebaseAuth override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_login) auth = Firebase.auth // ... }現在の認証状況を確認するためには、
FirebaseAuth
のcurrentUser
を取得することにより確認することができます。アプリ起動時にすでに認証済みの場合は、ログイン画面からログアウト画面に遷移するようにしました。override fun onStart() { super.onStart() val currentUser = auth.currentUser if (currentUser != null) LogoutActivity.start(this) }新規登録
メールアドレスとパスワードを使い新規登録する場合は、
auth.createUserWithEmailAndPassword()
を使用します。入力されたメールアドレスとパスワード使って新規登録されます。private fun createAccount(email: String, password: String) { auth.createUserWithEmailAndPassword(email, password) .addOnCompleteListener(this) { task -> if (task.isSuccessful) { Log.d(TAG, "createUserWithEmail:success") LogoutActivity.start(this) } else { Log.w(TAG, "createUserWithEmail:failure", task.exception) Toast.makeText( this, "Authentication failed.", Toast.LENGTH_SHORT ).show() } } }ログイン
すでに登録されているメールアドレスとパスワードを使ってログインする場合、
auth.signInWithEmailAndPassword()
を使用します。private fun signIn(email: String, password: String) { auth.signInWithEmailAndPassword(email, password) .addOnCompleteListener(this) { task -> if (task.isSuccessful) { Log.d(TAG, "signInWithEmail:success") LogoutActivity.start(this) } else { Log.w(TAG, "signInWithEmail:failure", task.exception) Toast.makeText( this, "Authentication failed.", Toast.LENGTH_SHORT ).show() } } }ログアウト
private fun signOut() { auth.signOut() }
- 投稿日:2020-09-14T01:51:08+09:00
Firestoreのキャッシュとうまく付き合う
Firebase Cloud Firestoreとキャッシュ
Firebase Cloud FirestoreはNoSQL方式のクラウドDBです。
使い時・使いどころはよぉく吟味する必要があるものの、昨今のWeb・モバイルの抱えるプロブレムにうまくターゲットしています。
インフラとしての出来の良さもさることながら、無料で提供されているSDKもまあまあ使いやすいです。
特に、DBから取得したデータを勝手にキャッシュしておいてくれてオフライン時でもシームレスにデータ参照できる仕組みはかなり頑張っているなという印象を受けます。Firestoreのキャッシュ戦略はいまいち
とはいえ全く不満がないわけではありません。
その1つがキャッシュの制御です。fun getCitySf() { val docRef = db.collection("cities").document("SF") docRef.get()... // 省略上記のコードでは、サーバーに問い合わせるかキャッシュを使うかはその時のネットワークの状況によって決定されます。
オンラインならサーバー問い合わせ、オフラインならキャッシュです。
しかし、オンラインであっても回線が細くて応答が遅い場合にはキャッシュを使ってほしいこともあるでしょうし、オフラインの場合でもサーバー問い合わせをした上で失敗を返して欲しいこともあるでしょう。リファレンスを読むと、
get
関数にソースオプションを渡せばサーバーに問い合わせるかキャッシュを使うが選べることがサラリと書いてありますが、具体的にどのような実装にすればよいかまでは書いていません。前置き
というわけで、ユースケースごとに実装を紹介していきます……とその前に、前置きです。
Firestoreは設計思想としてユーザー側に細かなキャッシュの制御を許していないフシがあります 。
したがってキャッシュの制御にこだわろうとすればするほど泥沼にハマっていきます。この点についてはこの記事の最後で改めて言及しております。
なので、実装のくだりだけ読んで即導入するのはお控えください。キャッシュとうまく付き合う実装
というわけで今度こそユースケースごとに実装を紹介していきます。
環境/準備
改めまして今回の環境です。
本筋とは関係ありませんが、addOnCompleteListener
を使った書き方は好きじゃないのでawait拡張関数を導入しています。build.gradle... dependencies { implementation 'com.google.firebase:firebase-firestore-ktx:21.2.1' // Task#await拡張を使っています: https://github.com/Kotlin/kotlinx.coroutines/tree/master/integration/kotlinx-coroutines-play-services def coroutines_version = '1.3.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutines_version" }パターン1:呼び出し側でキャッシュ使用かサーバー問い合わせかを指定する
はじめに紹介するのは誰でも思いつくやり方。
suspend fun getCitySf(usesCache: Boolean): City { val docRef = db.collection("cities").document("SF") val source = if (usesCache) Source.CACHE else Source.SERVER val city = docRef.get(source).await().let { ds: DocumentSnapshot -> // DocumentSnapshotからCity型への変換 ... } return city }引数にキャッシュを使うかどうかのフラグを指定させるだけです。
キャッシュの使用可否を上位層で決定してもよい場合はこれで十分でしょう。パターン2:キャッシュがあればキャッシュを使いなければサーバーに問い合わせる
しかし、「呼び出し時にいちいち
usesCache
を与えるのはいやだ!getCitySf
でいい感じに判断してくれ!!!」という場合も多々あるでしょう。
ここではいい感じ
=キャッシュがあればキャッシュを使い、なければサーバーに問い合わせ と解釈します。suspend fun getCitySf(): City { return try { getCityImpl(usesCache = true) } catch(t: Throwable) { getCityImpl(usesCache = false) } } private suspend fun getCitySfImpl(usesCache: Boolean): City { val docRef = db.collection("cities").document("SF") val source = if (usesCache) Source.CACHE else Source.SERVER val city = docRef.get(source).await().let { ds: DocumentSnapshot -> // DocumentSnapshotからCity型への変換 ... } return city }
getCitySfImpl
はパターン1のgetCitySf
をprivateにしてリネームしただけです。これを今回作るgetCitySf
の部品とします。
getCitySf
では、最初にキャッシュからのデータ取得を試みます(getCityImpl(usesCache = true)
)。
キャッシュが存在する場合はgetCityImpl(usesCache = true)
の結果がそのまま
getCitySf
の結果になります。
キャッシュが存在しない場合はgetCityImpl(usesCache = true)
は例外を送出するため、すかさずcatchして今度はサーバー問い合わせで取得します(getCityImpl(usesCache = false)
)。
サーバーへの問い合わせに成功すればgetCityImpl(usesCache = false)
がgetCitySf
の結果になります。
こうすればgetCitySf
は引数を取ることがなく、上位層(呼び出し側)でキャッシュの使用可否を指定することがなくなります。「いや、引数は“毎回”指定したくないだけでたまには指定したい場合もあるんだ……」という場合は省略可能引数を使って以下のようにしてみてはどうでしょうか。
enum GetType { CACHE, SERVER, CACHE_THEN_SERVER } suspend fun getCitySf(getType: GetType = GetType.CACHE_THEN_SERVER): City { return when(getType) { GetType.CACHE -> { getCitySfImpl(usesCache = true) } GetType.SERVER -> { getCitySfImpl(usesCache = false) } GetType.CACHE_THEN_SERVER -> { try { getCityImpl(usesCache = true) } catch(t: Throwable) { getCityImpl(usesCache = false) } } } } private suspend fun getCitySfImpl(usesCache: Boolean): City { // 以下省略 ... }パターン3:可能な限りサーバーに問い合わせてほしいが応答に時間がかかるようならキャッシュを使って欲しい
パターン2で言及した
いい感じ
には他にも種類があります。
たとえば、可能な限りサーバーに問い合わせてほしいが応答に時間がかかるようならキャッシュを使う
ことも1つでしょう。suspend fun getCitySf(): City { return try { val c = getCityImpl(usesCache = true) try { withTimeout(1000) { getCityImpl(usesCache = false) } } catch { c } } catch(t: Throwable) { // キャッシュがなかった場合の処理。 // キャッシュがない場合はどれだけ時間をかけてもいいからサーバーに問い合わせるようにする。 getCityImpl(usesCache = false) } } private suspend fun getCitySfImpl(usesCache: Boolean): City { // 以下省略 ... }パターン2と似たようなコードですが、キャッシュの取得に成功した後にサーバーへの問い合わせを行っています。その際、
withTimeout
内で実行しています。
withTimeout
はタイムアウト用のコルーチン関数で、指定した時間内に処理が終わらなかった場合例外が送出されます。
今回は1000ms以内にサーバーからの応答があった場合 1 、サーバーの問い合わせ結果を使っています。1000msを超えてもサーバーからの応答がなかった場合、catchでキャッシュの結果を返すようにしています。パターン2同様、呼び出し側から引数を与えて制御したいこともある場合はこうです。
enum GetType { CACHE, SERVER, SERVER_THEN_CACHE } suspend fun getCitySf(getType: GetType = GetType.SERVER_THEN_CACHE): City { return when(getType) { GetType.CACHE -> { getCitySfImpl(usesCache = true) } GetType.SERVER -> { getCitySfImpl(usesCache = false) } GetType.SERVER_THEN_CACHE -> { try { val c = getCityImpl(usesCache = true) try { withTimeout(1000) { getCityImpl(usesCache = false) } } catch { c } } catch(t: Throwable) { // キャッシュがなかった場合の処理。 // キャッシュがない場合はどれだけ時間をかけてもいいからサーバーに問い合わせるようにする。 getCityImpl(usesCache = false) } } } } private suspend fun getCitySfImpl(usesCache: Boolean): City { // 以下省略 ... }さらなるパターンと脱Firestoreの検討:Firestoreとうまく付き合おう
キャッシュ制御のパターンとして、他にも
- 鮮度の新しいデータはキャッシュを優先して鮮度の古いデータはサーバー問い合わせする
- ↑+パターン3を組み合わせる
などが考えられます。これらは鮮度(最後にデータをサーバーから取得した時刻)を管理しなければならないので少々厄介です。
また、アプリ再起動後も鮮度情報を維持しなければならない場合、鮮度の永続化処理も必要になります。つまりキャッシュの制御のためのキャッシュ(鮮度)制御です。ここまで来ると、 Firestoreとのお付き合いの方法も再検討するべきです 。
Firestoreに限ったことではありませんが、クラウドサービスは最大公約数的にソリューションを提供するものです。瑣末事や関心事の中心にない問題はうまく解決することはできません。
Firestoreの場合ですと、簡便に扱えることやオンライン/オフラインをシームレスに扱えることに重点を置いているので、キャッシュを細かく制御したいことはそもそも解決すべき問題として捉えていない可能性が高いです。あなたが作っているものが少しずつ育てていくプロダクトの場合、プロダクトを取り巻く問題は日々変わっていきます。
最初はマッチしていたプロダクトとクラウドサービスが段々ズレていくこともままあります。
そのような状況においてはFirestoreから脱却し、別のDBサービスや自前で用意したDBシステムに移行することも検討した方がいいでしょう。
そして移行までの間に今回紹介したパターンを“つなぎ”のテクニックとして導入するのはありだと思います。Firestoreのキャッシュとうまく付き合うためにはFirestoreそのものとうまく付き合っていきましょう。
正確には「1000ms以内にサーバーの問い合わせ結果がありCity型に変換できた場合」です。DocumentSnapshot型からCity型への変換処理に時間がかかる場合、DocumentSnapshotの取得処理とその後の変換処理は分けてください ↩
- 投稿日:2020-09-14T01:44:20+09:00
Pydroid 3 - IDE for Python 3 (Android) の有料オプションを試した
Pydroid 3 - IDE for Python 3 はAndroidで動くPythonですが、PC上のPythonと互換性が高くtkinterなどのGUIプログラムも同じソースを使うことができます。スマホだけでまあまあ使えるアプリが書けるのでお勧めです。さらにOpenCVやTensorFlowまで動くそうです。残念ながらこれらのライブラリは有料オプションとなっていますが、3日間のお試しができるので申し込んでみました。
有料オプション申し込み
有料オプションは次の2つの種類があります。
・買い切り¥1160
・月々¥110 最初の3日は無料
3日間お試しをやってみることにしました。申し込みは製品から行えます。
左上アイコンをクリックしてサイドメニューを開くと一番上に有料オプション申し込みメニューがあります。申し込みメニューに入ると上記2つのオプション選択メニューになります。
![]()
下の「3 DAY FREE TRIAL」をクリックします。
定期購入を押すと、個人認証ダイアログがでるので指紋で認証します。これで申し込み完了です。ライブラリインストール
申し込み完了すると、ライブラリメニューでグレーアウトしていたものがインストール可能になっています。
全部インストールしました。
tensorflow-2.2.0, opencv-python-4.3.0, torch-1.5.0 がインストールされました。サンプルプログラム
サイドメニューの Samples からサンプルプログラムをダウンロードできます。ここもグレーアウトしていたものが選択可能になっています。
Tensorflow
MNIST
よくあるやつです。サンプルデータをダウンロードして学習させるやつです。もの凄く遅いので、epoch=5に設定されています。5回まわすだけでも大変なんですが。モデルはKerasの普通のソースが使えそうです。
Image Classification
Text classification
衣類画像の識別のようです。最後にmatplotlibで画像とグラフが表示されます。
![]()
このサンプルソースをWindowsに持っていって走らせてみたら、そのまま1行も変えずに動かすことができました。
Regression
車のスペックから燃費を推測して、本当の値と比べてるのかな?
これも同じソースがWindowsでも動きます。OpenCV
Hello camera
カメラが安定しません。一度だけ動きましたが、キャプチャを撮るのを忘れてました。
Windowsで実行すると次のようなエラーがでまくります。[ WARN:0] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (374) `anonymous-namespace'::SourceReaderCB::OnReadSample videoio(MSMF): OnReadSample() is called with error status: -1072875772 [ WARN:0] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (386) `anonymous-namespace'::SourceReaderCB::OnReadSample videoio(MSMF): async ReadSample() call is failed with error status: -1072875772 [ WARN:1] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (906) CvCapture_MSMF::grabFrame videoio(MSMF): can't grab frame. Error: -1072875772 [ WARN:1] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (906) CvCapture_MSMF::grabFrame videoio(MSMF): can't grab frame. Error: -2147483638 [ WARN:1] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (906) CvCapture_MSMF::grabFrame videoio(MSMF): can't grab frame. Error: -2147483638Laplacian
これもカメラが安定しません。何度かやってると動きました。リアルタイムに輪郭抽出します。
Face detector
これはキャプチャのやり方が他と違うのか安定してます。リアルタイムに顔を検出します。
サンプルは自撮り用に前面カメラ(1)を使ってますが、カメラ番号を(0)にするとPCでも動作します。PyQt5をインストールする必要があります。
ASCII camera
これまたまたカメラが安定しません。右の人形を撮ると左のようにASCII文字で表示します。
![]()
Camera options
PyTorch
Tensors
MNIST
動いているようですが、ものすごく時間がかかります。Epoch=5 ですが、30分くらいかかりました。
Pretrained model
画像をダウンロードして何が写ってるか判定します。ダウンロード済の画像でも判定に数十秒かかりました。
![]()
Quantized model
このカメラは安定しているようです。カメラに写ったものをリアルタイムに識別します。ステンレスのタンブラーがカクテルシェーカーと判定されましたが似てるかも。
まとめ
互換性は高く、たいていのサンプルはPCでもそのまま動きます。OpenCVのサンプルでカメラが不安定なものがあるのが気になりますが、安定した動きをするサンプルもあるので回避する方法はありそうです。Pydroid3でカメラを使うには、別アプリで撮影し、すぐに切り替えて処理する手もありますか、リアルタイムにカメラ画像を使うには、この有料オブションを使うしかないでしょう。
モデルの学習もできますが、スマホは遅いので実用にはならないと思います。大量にデータを取ってバッチ的にイッキに学習させるというのは、気の向いたときに写真をとったりネットを見たり電話がかかってきたり、何かと割り込みが多いスマホには向いてません。ホームではなく出先でデータや写真を撮ってすぐに学習させていくというやり方がいいのかもしれません。
スマホでブログラムが組めるというのは楽しそうです。以前自分でもそういう環境( アプリを作るためのAndroidアプリ、「PineVentor」 )を作ったことがあります。ただしスマホだけで開発するのはなかなかキツいものがありました。Pydroid3はPCでも同じスクリプトが使えるので、いったりきたりしながら作るといいことがあるかもしれません。ただし今のところ気軽にいろんなところでスクリプトを渡り歩いていく仕組みはないので、いろいろ工夫する必要はありそうです。
出先にノートPCを持っていくほどではないが、ちょっとした空き時間にプログラムしてパズルをといたりゲームでズルしたりしたい人向けでしょうか。現場でデータを取ってすぐに時系列のグラフにすることができると、より深いデータ収集ができるかもしれません。無料のPydroid3でもかなり遊べるので、リアルタイムにカメラを使う必要がなければ、有料オプションはぜひとも必要ではありませんが、無料で3日お試しする価値はあると思います。
- 投稿日:2020-09-14T01:44:20+09:00
Pydroid 3 - IDE for Python 3 (Android) のOpenCVとTensorFlowオプションを試した
Pydroid 3 - IDE for Python 3 はAndroidで動くPythonですが、PC上のPythonと互換性が高くtkinterなどのGUIプログラムも同じソースを使うことができます。スマホだけでまあまあ使えるアプリが書けるのでお勧めです。さらにOpenCVやTensorFlowまで動くそうです。残念ながらこれらのライブラリは有料オプションとなっていますが、3日間のお試しができるので申し込んでみました。
※サンプルスクリプトはすべてPixel3で実行確認しています。機種によってはカメラが動かなかったりします。
有料オプション申し込み
有料オプションは次の2つの種類があります。
・買い切り¥1160
・月々¥110 最初の3日は無料
3日間お試しをやってみることにしました。申し込みは製品から行えます。
左上アイコンをクリックしてサイドメニューを開くと一番上に有料オプション申し込みメニューがあります。申し込みメニューに入ると上記2つのオプション選択メニューになります。
![]()
下の「3 DAY FREE TRIAL」をクリックします。
定期購入を押すと、個人認証ダイアログがでるので指紋で認証します。これで申し込み完了です。ライブラリインストール
申し込み完了すると、ライブラリメニューでグレーアウトしていたものがインストール可能になっています。
全部インストールしました。
tensorflow-2.2.0, opencv-python-4.3.0, torch-1.5.0 がインストールされました。サンプルプログラム
サイドメニューの Samples からサンプルプログラムをダウンロードできます。ここもグレーアウトしていたものが選択可能になっています。
Tensorflow
MNIST
よくあるやつです。サンプルデータをダウンロードして学習させるやつです。もの凄く遅いので、epoch=5に設定されています。5回まわすだけでも大変なんですが。モデルはKerasの普通のソースが使えそうです。
Image Classification
Text classification
衣類画像の識別のようです。最後にmatplotlibで画像とグラフが表示されます。
![]()
このサンプルソースをWindowsに持っていって走らせてみたら、そのまま1行も変えずに動かすことができました。
Regression
車のスペックから燃費を推測して、本当の値と比べてるのかな?
これも同じソースがWindowsでも動きます。OpenCV
古い機種ではOpenCVがダウンロードができないものがありました。別の機種ではカメラが動作しない、カメラが使える機種でも動作が安定しない、などカメラ機能は問題があります。
Hello camera
カメラが安定しません。一度だけ動きましたが、キャプチャを撮るのを忘れてました。
Zenfone3 Maxで試したところ次のようなメッセージがでて動作しませんでした。
Camera NDK APIをサポートしている機種でないとダメみたいです。
Windowsで実行すると次のようなエラーがでまくります。
[ WARN:0] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (374) `anonymous-namespace'::SourceReaderCB::OnReadSample videoio(MSMF): OnReadSample() is called with error status: -1072875772 [ WARN:0] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (386) `anonymous-namespace'::SourceReaderCB::OnReadSample videoio(MSMF): async ReadSample() call is failed with error status: -1072875772 [ WARN:1] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (906) CvCapture_MSMF::grabFrame videoio(MSMF): can't grab frame. Error: -1072875772 [ WARN:1] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (906) CvCapture_MSMF::grabFrame videoio(MSMF): can't grab frame. Error: -2147483638 [ WARN:1] global C:\projects\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (906) CvCapture_MSMF::grabFrame videoio(MSMF): can't grab frame. Error: -2147483638Laplacian
これもカメラが安定しません。何度かやってると動きました。リアルタイムに輪郭抽出します。
Face detector
これはキャプチャのやり方が他と違うのか安定してます。リアルタイムに顔を検出します。
サンプルは自撮り用に前面カメラ(1)を使ってますが、カメラ番号を(0)にするとPCでも動作します。PyQt5をインストールする必要があります。
ASCII camera
これまたまたカメラが安定しません。右の人形を撮ると左のようにASCII文字で表示します。
![]()
Camera options
PyTorch
Tensors
MNIST
動いているようですが、ものすごく時間がかかります。Epoch=5 ですが、30分くらいかかりました。
Pretrained model
画像をダウンロードして何が写ってるか判定します。ダウンロード済の画像でも判定に数十秒かかりました。
![]()
Quantized model
このカメラは安定しているようです。カメラに写ったものをリアルタイムに識別します。ステンレスのタンブラーがカクテルシェーカーと判定されましたが似てるかも。
まとめ
互換性は高く、たいていのサンプルはPCでもそのまま動きます。OpenCVのサンプルでカメラが不安定なものがあるのが気になりますが、安定した動きをするサンプルもあるので回避する方法はありそうです。Pydroid3でカメラを使うには、別アプリで撮影し、すぐに切り替えて処理する手もありますか、リアルタイムにカメラ画像を使うには、この有料オブションを使うしかないでしょう。ただし、サポートしていない機種の場合はどうにもなりません。
モデルの学習もできますが、スマホは遅いので実用にはならないと思います。大量にデータを取ってバッチ的にイッキに学習させるというのは、気の向いたときに写真をとったりネットを見たり電話がかかってきたり、何かと割り込みが多いスマホには向いてません。ホームではなく出先でデータや写真を撮ってすぐに学習させていくというやり方がいいのかもしれません。
スマホでブログラムが組めるというのは楽しそうです。以前自分でもそういう環境( アプリを作るためのAndroidアプリ、「PineVentor」 )を作ったことがあります。ただしスマホだけで開発するのはなかなかキツいものがありました。Pydroid3はPCでも同じスクリプトが使えるので、いったりきたりしながら作るといいことがあるかもしれません。ただし今のところ気軽にいろんなところでスクリプトを渡り歩いていく仕組みはないので、いろいろ工夫する必要はありそうです。
出先にノートPCを持っていくほどではないが、ちょっとした空き時間にプログラムしてパズルをといたりゲームでズルしたりしたい人向けでしょうか。現場でデータを取ってすぐに時系列のグラフにすることができると、より深いデータ収集ができるかもしれません。無料のPydroid3でもかなり遊べるので、リアルタイムにカメラを使う必要がなければ、有料オプションはぜひとも必要ではありませんが、無料で3日お試しする価値はあると思います。
機種によってはカメラが動かなかったり、古い機種だとOpenCVまるごと動かなかったりするので、購入するにしても゛お試しはやった方がいいです。