- 投稿日:2020-06-28T22:37:28+09:00
Androidのウィジェットでできること、できないこと
iOS 14でホーム画面ウィジェットが使えるようになるそうです。Androidでは昔からウィジェットがありましたが、このタイミングでAndroidのウィジェットにももう一度注目が集まりそうな予感がします。しかし、ウィジェットはかなり制約が大きく、何ができて何ができないのかをエンジニア以外に理解してもらうのは難しかったりします。
ここでは、どうやって作るかという話ではなく、Android開発に詳しくない人や非エンジニア向けに、ウィジェットってどういうものなのか、何ができて何ができないのかをざっくり理解していただけるような内容で書いてみようと思います。ウィジェットの制約の考え方
あるアプリのウィジェットを表示しているのは、そのアプリ自身ではなく、ホームアプリ等のウィジェットが設置されているアプリです。ウィジェットを提供するアプリは、こういう内容を表示してくださいとお願いすることしかできません。
まずはここを押さえておいてもらえば以降の制約についても理解しやすいかもしれません。実際に表示しているのは別のアプリなので、ウィジェットが表示されている間、アプリのプロセスは生きているとは限りません。更新のタイミングでは起動しますが、更新のタイミング以外では、アプリは何もしていないのでプロセスも終了している可能性があります。当然バックグラウンドで動作することになるので、バックグラウンド動作の制約もかかります(位置情報へのアクセスに別のパーミッションが必要など)
全部を制御できてほぼ何でもありのアプリの画面とは全然ちがって、できないことが多く、仮にできたとしても、かなり工夫しないといけません。
使えるView(UIのパーツ)が限られる
アプリの画面上では何でもありですが、ウィジェットは使うことができるView(UIのパーツ)が限定されています。
レイアウトは以下の4種類
- FrameLayout
- レイヤーを重ねるようにViewを重ねて表示するLayoutです。それぞれの要素の上下左右のマージンを指定できるので親を絶対座標とする配置が可能です
- LinearLayout
- Viewを縦、もしくは横方向に並べるLayoutです
- RelativeLayout
- 他のViewを基準に相対的な位置関係を指定して配置していくViewです。自由度が高いですがLegacy扱いになっています
- GridLayout
- Viewを格子状に配置するLayoutです
Viewは以下のみ
- AnalogClock
- Button
- Chronometer
- ImageButton
- ImageView
- ProgressBar
- TextView
- ViewFlipper
- ListView
- GridView
- StackView
- AdapterViewFlipper
- できることはViewFlipperと同じですが、実装方法が異なります
ポイントは、この制限は本当にこのViewしか使えないということです。
Viewは拡張元、拡張先の親子関係を持っているのですが、ここに上げたViewの親や子は使うことができません。すべてのViewは「View」というクラスを拡張して作られていますが、Viewは使える要素に含まれていないため、View自体を使うことができません。
同様に独自に拡張したものやライブラリ提供のViewも一切使うことができません。できるのは、ここにあるViewを組み合わせて使うことだけです。
OSバージョンによる動作の違いが大きく出る
通常アプリ開発では、AppCompatという新OSの機能を古いOS上でも使えるようにして、OSバージョン間の差異を吸収するライブラリを使用します。
使えるViewの中にあるView、例えば、TextViewも実際はAppCompatTextViewというTextViewを拡張したクラスが使われますが、ウィジェットではAppCompatのクラスは使えません。OSバージョン間の差異を吸収するライブラリが使えませんので、当然OSバージョン間での動作の違いなどが現れやすく、かつ、それが問題となった場合に解決が困難です。
Viewの制限によりできなくなることの代表例
Viewに制限があって使えるのはこれだけです、といわれても、何ができて何ができないのかピンとこないでしょう。
具体的にこれできる?と言われそうでできないことの例を上げてみようと思います。
- Webコンテンツの表示
- WebViewは使えません
- 動画コンテンツの表示
- VideoViewやSurfacaViewは使えません
- 3Dレンダリング
- GLSurfaceViewは使えません
Viewに対する命令の制限
使えるViewに制限がある話をしましたが、使えるViewに対しても可能な命令が制限されます。
この制限をエンジニア以外に分かってもらうのは難しいですが、わかりやすいところで言うと、表示させられる画像リソースは、ビルド時に固定で組み込んだリソース、もしくはBitmapのみという制約があります。
例えば、角丸や縁取りの色や太さを動的に変更できるようにして欲しいというリクエストをされると、すごくハードルが上がります。操作
ウィジェットで受け取ることができる操作はタップのみです。それ以外の操作を受け取ることはできません。
スクロール可能なViewを配置している場合は、スクロール操作がウィジェットに適用されますが、その操作をアプリで受け取ることはできません。そのため、例えば、お絵かきソフトのようにドラッグした軌跡を書き出すといったことはできません。
表示場所の制御、取得
- ウィジェットが追加された
- ウィジェットが削除された
- 表示サイズが変わった
といった情報は受け取ることができるため、どのような大きさで何個設置されているか、をアプリから知ることは可能です。
しかし、それがどのランチャーアプリに設置されているのか、ランチャーアプリ内のどこページのどの位置なのか、といった情報を取ることはできません。
ウィジェットがユーザーから見える位置にあるか否かも知ることはできません。表示の更新
ウィジェットの属性で更新周期を指定することができますが、30分以下を指定すると無視され、更新されなくなります。
端末のWakeupが伴うため、1時間に1回以下にすることが推奨されています。これとは別に、アプリ側から更新を要求することができます。
このときは当然アプリのプロセスが生きている必要がありますが、能動的な更新が可能なので、アプリ側の定期実行の仕組みを使えば、ある程度柔軟な制御ができます。また、ウィジェット上のボタンのタップなどでアプリにメッセージを飛ばすことができますので、このタイミングで更新をかけることもできます。いずれにせよ、アプリはバックグラウンドで動作することになりますので、高頻度な更新はできないと思った方がよいでしょう。
当然アニメーションもあらかじめ決められた動きの一部は可能ですが、自由度はかなり限られます。アプリがバックグラウンドで常駐して、表示する内容を高速に更新、という方法は使えなくもないでしょうが、垂直同期を取ることはできませんし、常駐状態でこのような処理をするのは現実的ではないです。無理にぶん回せばこのぐらいの更新はできるみたいですが
表示サイズの制御
表示サイズとして指定できるのは以下です
- デフォルトの高さ、幅
- 最小の高さ、幅
- 高さ、幅それぞれが可変か否か
セル数でサイズの指定はできない
まず、高さ、幅の指定で使う単位は dp です。一般的なランチャーアプリでは、「3 x 1」のように表示されるセル数で表示されているため、セル数を指定したくなるかもしれませんが、これは不可能です。セルのサイズおよびマージンは端末やランチャーアプリによって異なります。場合によっては縦向きか横向きかでも変わります。ウィジェットを提供するアプリはデフォルトのレイアウトが収まる具体的なサイズを指定し、ホームアプリは自分のセルサイズから計算して、それが納まる最小のセル数を表示します。
セル数サイズが変わっては困る仕様は避ける必要があります(例えば名前に「1 x 1ウィジェット」とサイズの入った名前をつけるなど)上限は指定できない
高さ、幅、それぞれについて、デフォルト、最小、可変か否か、しか指定できません。上限がないため、可変にすると上限なしとするしかありません。
デフォルトの大きさは「1 x 1」だけど、「1 x 2」までは拡大できる、それ以上はできない、みたいな仕様は実現できません。
広い領域が割り当てられても、その中心にだけ表示するという制御は可能です。まとめ
思いつく範囲で、Androidのウィジェットの制約をまとめました。
あれが書かれてない、これ間違っている、などありましたらご指摘をお願いします。
- 投稿日:2020-06-28T19:25:29+09:00
リップルカラーの変更方法
はじめに
Buttonのリップルカラーを変更したいと思ったが、思い通りの色に変更することができなかった。 ここではButton(Material)のリップルカラーの変更方法の手順をまとめます。
目標
Layout
<?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" > <com.google.android.material.button.MaterialButton android:id="@+id/button" style="@style/Widget.MaterialComponents.Button.OutlinedButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Button" android:theme="@style/Theme.MaterialComponents" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:strokeColor="?attr/colorPrimary" /> </androidx.constraintlayout.widget.ConstraintLayout>リップルカラーをいじっていないボタンを生成します。 この状態だとリップルカラーは
colorPrimary
になっているのと思います。
今回は黒のリップルカラーにしたいので、rippleColor
というアトリビュートを変えればいいのか!と思って黒に変更してみたが。。。activity_main.xml... <com.google.android.material.button.MaterialButton ... app:rippleColor="@color/colorBlack" ...
あれ、なんか濃くね?
ここで、デフォルトのボタンがどのようになっているかxml
をたどってみました。
<item name="rippleColor">@color/mtrl_btn_text_btn_ripple_color</item>
このmtrl_btn_text_btn_ripple_color
を見てみると、mtrl_btn_text_btn_ripple_color.xml<selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:alpha="@dimen/mtrl_low_ripple_pressed_alpha" android:color="?attr/colorPrimary" android:state_pressed="true"/> <item android:alpha="@dimen/mtrl_low_ripple_focused_alpha" android:color="?attr/colorPrimary" android:state_focused="true" android:state_hovered="true"/> <item android:alpha="@dimen/mtrl_low_ripple_focused_alpha" android:color="?attr/colorPrimary" android:state_focused="true"/> <item android:alpha="@dimen/mtrl_low_ripple_hovered_alpha" android:color="?attr/colorPrimary" android:state_hovered="true"/> <item android:alpha="@dimen/mtrl_low_ripple_default_alpha" android:color="?attr/colorPrimary"/> </selector>ここで
alpha(不透明度)
を設定していた。なので、この?attr/colorPrimary
を自分のしたい色に変更してやれば良さそう。btn_ripple_color.xml<selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:alpha="@dimen/ripple_pressed_alpha" android:color="@color/black" android:state_pressed="true" /> <item android:alpha="@dimen/ripple_focused_alpha" android:color="@color/black" android:state_focused="true" android:state_hovered="true" /> <item android:alpha="@dimen/ripple_focused_alpha" android:color="@color/black" android:state_focused="true" /> <item android:alpha="@dimen/ripple_hovered_alpha" android:color="@color/black" android:state_hovered="true" /> <item android:alpha="@dimen/ripple_default_alpha" android:color="@color/black" /> </selector>pressedやfocusedは、ボタンを押した時やフォーカス時の色は設定できます。(正直ボタンをfocuseやhoverってあんまり実感わかない)
作成したxmlファイルを、rippleColor
に適応させる。activity_main.xml... <com.google.android.material.button.MaterialButton ... app:rippleColor="@drawable/btn_ripple_color" ...以上でリップルカラーを変更できました!
- 投稿日:2020-06-28T18:49:07+09:00
FlutterでWeb,Android,iOSの3プラットフォームから同じコードでS3に写真をアップロードする
概要
6月にWeb向けのImagePickerが出ていたので、ネイティブアプリとブラウザアプリを同じソースコードでビルドして、どのデバイスでもファイルアップロード出来るのかというのを試しました。
AndroidとiOSに加えてWebでもPickerが同じ処理で使えるようになったのは結構画期的だなと思ってのメモです。内容はクライアント側に重点を置いています。1. パッケージをインポートする。
pubspec.yamlにimage_pickerとimage_picker_for_web、amazon_cognito_identity_dart_2を追加する。
私が試したバージョンは以下です。image_picker: ^0.6.7 image_picker_for_web: ^0.1.0+1 amazon_cognito_identity_dart_2: ^0.1.142. 画像取得処理を書く。thenを使用してますが、awaitでも問題ありません。
import 'package:image_picker/image_picker.dart'; ~ ~ var picker = ImagePicker(); picker.getImage(source: ImageSource.gallery) .then((PickedFile value) { // ここから必要に応じて後続の処理を呼び出す });3. アップロード処理を書く。
こちら( https://pub.dev/packages/amazon_cognito_identity_dart_2#signing-requests )のページのFor S3 Uploadsをベースにしますが、PickedFileはlengthが取れないので少し改造します。PolicyやSigV4はそのままで
main()
だけ変更します。
私の場合はuploadImage
として以下のようにしました。Future<String> uploadImage(PickedFile file) async { final _credentials = CognitoCredentials(ID_POOL_ID, _userPool); final _cognitoUser = CognitoUser(_username, _userPool); final authDetails = AuthenticationDetails(username: _username, password: _passwd); CognitoUserSession _session; try { _session = await _cognitoUser.authenticateUser(authDetails); } catch (e) { print(e); return null; } await _credentials.getAwsCredentials(_session.getIdToken().getJwtToken()); final uri = Uri.parse(_s3Endpoint); final req = http.MultipartRequest("POST", uri); final multipartFile = http.MultipartFile.fromBytes('file', await file.readAsBytes(),contentType: MediaType('application', 'octet-stream')); logger.d('Policy.fromS3PresignedPost'); var uuid = Uuid(); String filename = 'test.jpg'; final policy = Policy.fromS3PresignedPost( filename, _backetName, 150, _credentials.accessKeyId, 10000000, _credentials.sessionToken, region: REGION); final key = SigV4.calculateSigningKey( _credentials.secretAccessKey, policy.datetime, REGION, 's3'); final signature = SigV4.calculateSignature(key, policy.encode()); req.files.add(multipartFile); req.fields['key'] = policy.key; req.fields['acl'] = 'public-read'; req.fields['X-Amz-Credential'] = policy.credential; req.fields['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256'; req.fields['X-Amz-Date'] = policy.datetime; req.fields['Policy'] = policy.encode(); req.fields['X-Amz-Signature'] = signature; req.fields['x-amz-security-token'] = _credentials.sessionToken; try { final res = await req.send(); logger.d('res:${res.toString()}'); await for (var value in res.stream.transform(utf8.decoder)) { logger.d(value); } return filename; } catch (e, stacktrace) { logger.e(e); logger.e(stacktrace); } return null; } }ID_POOL_IDや_userPoolは各環境に合わせて定義してください。
fromS3PresignedPostへ渡すパラメータmaxFileSizeは一旦10Mとしているので必要に応じて変更してください。
リンク先と主に異なるのは、final file = File(path.join('/path/to/my/folder', 'square-cinnamon.jpg'));
がPickedFile
に置き換わっていて、
streamからMultipartFileを生成する処理final stream = http.ByteStream(DelegatingStream.typed(file.openRead())); final multipartFile = http.MultipartFile('file', stream, length, filename: path.basename(file.path));が
bytesからMultipartFileを生成する処理final multipartFile = http.MultipartFile.fromBytes('file', await file.readAsBytes(),contentType: MediaType('application', 'octet-stream'));になっております。目的としてはlengthが取れないからbytesでのリクエストにしました。
クライアント側はこんな感じです。
他に
- Cognitoのユーザプール作成
- IDプール作成
- IAM追加
- S3のCORS設定
- iOSのNSPhotoLibraryUsageDescription
が必要です。特にS3のCORS設定はブラウザからのみアップロードできない問題に直面するので、ハマりポイントとして記載します。(その他は調べれば出てくると思うので他の方の記事をご参照ください。)
4. サーバ側番外編 S3でCORSの設定
こちら( https://docs.aws.amazon.com/ja_jp/sdk-for-javascript/v2/developer-guide/cors.html )のCORS設定例を参考にS3のバケット>アクセス権限>CORSの設定で設定してください。
AllowedOrigin
だけ各環境に合わせて変える必要があります。
ローカル環境からの試しであればアスタリスク*
で設定すると早いです。<?xml version="1.0" encoding="UTF-8"?> <CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <CORSRule> <AllowedOrigin>https://example.org</AllowedOrigin> <AllowedMethod>HEAD</AllowedMethod> <AllowedMethod>GET</AllowedMethod> <AllowedMethod>PUT</AllowedMethod> <AllowedMethod>POST</AllowedMethod> <AllowedMethod>DELETE</AllowedMethod> <AllowedHeader>*</AllowedHeader> <ExposeHeader>ETag</ExposeHeader> <ExposeHeader>x-amz-meta-custom-header</ExposeHeader> </CORSRule> </CORSConfiguration>
- 投稿日:2020-06-28T15:40:56+09:00
フォークしたリポジトリをライブラリとして取り込む
ケースとしてはあまり多くないとおもいますが、掲題のとおりフォークしたライブラリをプロジェクトに取り込む方法について記載します
経緯としては、以下のYahooのライブラリを改造して使いたかったためです。
https://github.com/yahoojapan/AppFeedback-android通常なら類似のライブラリを探すのでしょうけど、今回は見当たらなかったためForkすることにしてみました。
Forkが完了すると以下な感じにリダイレクトします
https://github.com/harutamasato/AppFeedback-androidsdkディレクトリ内でいじりたい箇所をいじって、masterにコミットしていきます。
$ git commit -a -m "Message" $ git push origin master
- 使える状態になったらタグをきってPushします。 2020/6/28現在、オリジナルの最新が1.0.5なのでそれ以上のものを設定します。 今回は1.0.6とします。正常にPushされると、以下のページのTagとReleaseに 1.0.6が並びます。
$ git tag -a "1.0.6" $ git push origin 1.0.6
- ライブラリを使う側はプロジェクトレベルのbuild.gradleに以下がなければ追記
build.gradleallprojects { repositories { maven { url "https://jitpack.io" } } }
- 最後に以下をapp/build.gradleに追記してSyncして使うだけでOK。
app/build.gradle dependencies { implementation 'com.github.harutamasato.AppFeedback-android:sdk:1.0.6' }
- 投稿日:2020-06-28T15:13:28+09:00
別途アプリをインストールせずに、決まった時間にAndroid端末でアプリを起動する
きっかけ
日付や時間帯を指定して、起動したいアプリがあります。たとえばmineoアプリなど、毎日一回アプリを起動させて、起動ボーナスの回収と「ゆずるね」の宣言を行いたい場合などがあります。
そういうときのためにAutomateItなどのアプリもあるのですが、なにぶんAutomateItは多機能な分端末にかける負荷も大きく、普段使いするような端末にはあまり入れたくありません。
そういうときには、Join by Joaoapps(以下Join)のAPIを使う という方法があります。これを使うとAPI経由で特定のURLをブラウザに開かせたり、アプリを起動させたりといったことがURLを叩くだけで実現できます。
やりかた
まず自分のAPI KEYとデバイスIDを確認するため、Join On The Webを開きます。
なぜか文字が重なって表示されてしまうのですが、とりあえずクリックすれば端末名は表示されるので、起動したい端末を選びます。
次に、JOIN APIのボタンをクリックします。
API Key: の右にある「SHOW」ボタンを押したあと、画面をスクロールして「App Package To Launch」の行に、起動したいアプリのパッケージ名を入れます。
PCで確認することが困難な場合は、Playストアでアプリのページを開くと確認できます。
ついでJoin On The Webの画面で上の方にスクロールすると、「Fill in fields below then select -> Copy URL to use」の行に「joinjoaomgcd.appspot.com」からはじまる長いURLが表示されていますので、これをコピーします。
このURLを定期的に実行されるような何かで呼び出すようにすればOKです。
たとえばPowerShellの場合は、次のようなコードになります。
curl "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush?appPackage=jp.mineo.app.mineoapp&deviceId=...&apikey=..."GASで定期的に実行する
ローカルにファイルを置いてしまうとバックアップやPC復元時の設定が面倒なので、こういうときはGoogle Apps Scriptなどで処理してしまうと楽です。
GoogleドライブでGoogle Apps Scriptのプロジェクトを作成し、次のようなコードを書きます。
コード.gsfunction run() { const url = "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush?appPackage=jp.mineo.app.mineoapp&deviceId=...&apikey=..." UrlFetchApp.fetch(url) }これを保存し、トリガーの設定で、「毎日n時台に実行」などとやっておけばOKです。
動作テストをしたい場合は「時間ベースのトリガーのタイプを選択」を「分ベースのタイマー」、「時間の間隔を選択(分)」を「1分おき」などとしておくと、1分後にスクリプトが実行されるので動作を確認しやすいですね。
おまけ:JOIN APIのエントリポイント
JOIN APIの解説ページを見ると、見出しが「API Key」と「Send Push」と「Notification Field」と「List Devices」しかなく、URLっぽいものが「List Devices」の真下にしかないため、一見して「どう呼べば良いんだ?」と思ってしまうこともあると思います(わたしはあった)
ただ先にJoin On The Webのほうをちゃんと見れば分かるとおり、「Send Push」のエントリポイントを叩くURLはJoin On The Webのほうを確認すれば分かりますので、ここには記載されていません。
ちなみに、下にある「List Devices」というAPIもちゃんとあり、Joinに登録されている(アカウントと紐付けされた)デバイスの情報が出力されます。
curl "https://joinjoaomgcd.appspot.com/_ah/api/registration/v1/listDevices?apikey={Join On The WebのJOIN APIボタンにて確認できるAPI Key}"注意点
特にないですが、Join On The Webに書かれているとおりAPI Keyさえ分かってしまえば認証など一切無しでデバイスが操作できますので、API Keyが外部に漏れないように気をつけましょう。
また、Join On The Webで文字が重なってしまうことなどから、端末名はちゃんと指定するようにしましょう。
- 投稿日:2020-06-28T15:00:27+09:00
パッケージ名からアプリ名(AndroidManifestのlabel属性のもの)を取得する
暗黙的Intentで選択したアプリ名を使いたくて、パッケージ名からアプリ名を取得する必要があったため備忘録含め以下に展開します。
こちらのgetApplicationLabelを使うと以下のようになります。
※loadLabelでも可なお、パッケージ名はいくつか取得方法があると思いますがいつかよく使う、URLをブラウザで開くケースを載せておきます。
Case1.ktoverride fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val launchIntent = Intent(Intent.ACTION_VIEW) val url = "https://www.google.com/?hl=ja" launchIntent.data = Uri.parse(url) val intentPick = Intent() intentPick.action = Intent.ACTION_PICK_ACTIVITY intentPick.putExtra(Intent.EXTRA_TITLE, "Launch using") intentPick.putExtra(Intent.EXTRA_INTENT, launchIntent) startActivityForResult(intentPick, 1) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == 1) { val packageName = data?.component!!.packageName // パッケージ名取得方法① val appName = packageManager.getApplicationLabel(packageName, PackageManager.GET_META_DATA).toString() startActivity(data) } }Case2.ktoverride fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val url = "https://www.google.com/?hl=ja" val intent = Intent(Intent.ACTION_VIEW) intent.data = Uri.parse(url) val pendingIntent = PendingIntent.getBroadcast( this, 123, Intent(this, ShareBroadcastReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT ) val chooser = Intent.createChooser(intent, null, pendingIntent.intentSender) button.setOnClickListener { if (intent.resolveActivity(packageManager) != null) { startActivity(chooser) } } } class ShareBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val packageName2 = intent?.getParcelableExtra<ComponentName>(Intent.EXTRA_CHOSEN_COMPONENT)?.packageName // パッケージ名取得方法② val keys = Objects.requireNonNull(intent!!.extras)?.keySet() if (keys != null) { for (key in keys) { try { val packageName = (intent!!.extras!![key] as ComponentName?)?.packageName// パッケージ名取得方法③ val packageManager: PackageManager? = context?.packageManager val appName = packageManager?.getApplicationLabel(packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)) as String } catch (e: Exception) { e.printStackTrace() } } } } }上記で取得できるパッケージ名例は以下
com.android.chrome org.mozilla.firefox jp.co.fenrir.android.sleipnir取得できるラベル名は以下(※必ずしもアプリ名とは一致しません)
Chrome Firefox Sleipnir
- 投稿日:2020-06-28T14:13:22+09:00
【Android】DataBindingのvariableはcamelCaseで書くのが無難
<variable name="hoge_text" type="String">より、
<variable name="hogeText" type="String">のが困らないと思うっすという話。プチハマったので。
具体例
「被
<include>
ファイルでsnake_caseのvariableを使おうとしたらアクセスできなかった」書いてみるxml
child.xml<layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="value_text" type="String" /> </data> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{value_text}"/> </layout>parent.xml<親要素省略> <include layout="@layout/child" android:layout_width="wrap_content" android:layout_height="wrap_content" app:value_text="@{someVariable}"/> </親要素省略>生成されるコード
ChildBindingImpl.javapublic void setValueText(@Nullable java.lang.String ValueText) { this.mValueText = ValueText; synchronized(this) { mDirtyFlags |= 0x2L; } notifyPropertyChanged(BR.value_text); super.requestRebind(); }ParentBindingImpl.java@Nullable private final app.package.name.databinding.ChildBinding mboundView; // ~~~ 略 ~~~ this.mboundView.setValue_text(someVariableGetValue); // <- お前ー!!エラー内容
エラー: シンボルを見つけられません this.mboundView3.setValue_text(someVariableGetValue); ^ シンボル: メソッド setValue_text(String)というわけで、
app:snake_case
属性により生成される関数名が、
<variable name="snake_case">
により生成される関数名と異なるためビルドに失敗します。「じゃあ
app:camelCase
&<variable name="snake_case">
ならどうよ」という気もしますが、
その場合はxmlファイル側で名前の解決が出来ず、BindingImplの生成前にエラーになるようです。書く側としてもxml内でその切り替えはしんどいっす。(自分の場合)対処
冒頭に書いた通りです。
child.xml<layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="valueText" type="String" /> </data> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{valueText}"/> </layout>parent.xml<親要素省略> <include layout="@layout/child" android:layout_width="wrap_content" android:layout_height="wrap_content" app:valueText="@{someVariable}"/> </親要素省略>定義からcamelCaseにしましょう。
おまけ
あんまり明言はされてないと思うんですが、
公式のサンプルでも2単語の変数名をさがしました。camelCaseでした。(userList
)<data> <import type="com.example.User"/> <import type="java.util.List"/> <variable name="user" type="User"/> <variable name="userList" type="List<User>"/> </data>このあたり(じゃあresourceのidもcamelCaseにしとく? とか)はGoogle提供のコードでも一貫されていない感があるので、昔から小さく話題になることみたいですね。
- 投稿日:2020-06-28T11:33:53+09:00
【Kotlin】Firebase + CameraX でリアルタイム文字認識
Firebase ML Kit + CameraX でリアルタイム文字認識してBottomSheetに表示します。
デモ
完成形はこんな感じです。
CameraX+MLkitでリアルタイム文字認識 pic.twitter.com/rjxxYrfIb4
— marica (@tama_Ud) June 28, 2020おおまかな処理の流れ
カメラ起動
↓
MLKitでリアルタイム文字認識
↓
BottomSheetに認識したテキストをリアルタイム表示さっそく作る
開発環境
・Windows 10
・Android Studio 3.6.3事前準備
1. Firebaseの設定
今回はテキスト認識とラベリングを使用します。
以下URLを参照して設定してください。・テキスト認識
https://firebase.google.com/docs/ml-kit/android/recognize-text?hl=ja
今回はデバイスモデルを使用します。
デバイスモデルで認識可能な言語はラテン文字のみです。
その他日本語などを認識したい場合はクラウドモデルを使いましょう。(月1000回まで無料のようです)・ラベリング
https://firebase.google.com/docs/ml-kit/android/label-images?hl=ja2. CameraXの設定
build.gradle(Module.app)ファイルのdependenciesブロックに以下を追記します。
build.gradle(Module.app)dependencies { … 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" … }同じくbuild.gradle(Module.app)ファイルのandroidブロック末尾に以下を追記します。
build.gradle(Module.app)android { … compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } }3. AndroidManifestに追記
AndroidManifest.xml<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.websarva.wings.android.your_project_name"> <uses-feature android:name="android.hardware.camera.any" /> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <application …> … <meta-data android:name="com.google.firebase.ml.vision.DEPENDENCIES" android:value="ocr, label" /> </application> </manifest>以上を書き込んだらAndroidStudioの "SyncNow" ボタンをクリックし、無事ビルドされることを確認します。
レイアウトを作成する
activity_mainの内容を以下に置き換えます。
LinearLayoutでBottomSheetを作っています。activity_main.xml<?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout 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"> <androidx.camera.view.PreviewView android:id="@+id/viewFinder" android:layout_width="match_parent" android:layout_height="match_parent" /> <LinearLayout android:id="@+id/bottomSheetLayout" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/cardview_light_background" android:orientation="vertical" app:behavior_hideable="false" app:behavior_peekHeight="200dp" app:layout_behavior="@string/bottom_sheet_behavior"> <TextView android:id="@+id/bottomSheetText" android:layout_width="300dp" android:layout_gravity="center" android:layout_height="wrap_content" /> </LinearLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>メイン処理を書く
大枠としてはこんな感じです。
これから具体的な処理を肉付けしていきます。MainActivity.kttypealias ODetection = (odt: Array<String?>) -> Unit private const val TAG = "CameraXBasic" class MainActivity : AppCompatActivity() { companion object { private const val TAG = "CameraXBasic" private const val REQUEST_CODE_PERMISSIONS = 10 private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // TODO: not yet implement } //MARK: ===== カメラ起動 ===== private fun startCamera() { // TODO: not yet implement } class ImageAnalyze (private val listener: ODetection): ImageAnalysis.Analyzer { //TODO: not yet implement } }カメラの処理を書いていきます。
onCreate内に以下の処理を書き足します。onCreate(MainActivity.kt)override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // BottomSheetを設定 bottomSheetBehavier = BottomSheetBehavior.from(bottomSheetLayout) // Request camera permissions if (allPermissionsGranted()) { startCamera() } else { ActivityCompat.requestPermissions( this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS ) } // Setup the listener for take photo button outputDirectory = getOutputDirectory() cameraExecutor = Executors.newSingleThreadExecutor() }onCreateの下に以下のメソッドを書き足します。
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() } } } 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() } } return if (mediaDir != null && mediaDir.exists()) mediaDir else filesDir }startCamera内に以下を書き足します。
startCamera(MainActivity.kt)private fun startCamera() { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) val frameLayout = FrameLayout(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() imageCapture = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .build() imageAnalyzer = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { // OCRの結果 it.setAnalyzer(cameraExecutor, ImageAnalyze { txtArr -> var showTxt = "" frameLayout.removeAllViews() for (txt in txtArr){ txt?.let{ showTxt += " $txt" } } bottomSheetText.text = showTxt Log.d(TAG, "listener fired!: $showTxt") }) } // 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)) }画像処理部分を書いていきます。
インナークラスであるImageAnalyzeに以下を追記します。ImageAnalyze(MainActivity.kt)class ImageAnalyze (private val listener: ODetection): ImageAnalysis.Analyzer { val options = FirebaseVisionOnDeviceImageLabelerOptions.Builder() .setConfidenceThreshold(0.7f) .build() val labeler = FirebaseVision.getInstance().getOnDeviceImageLabeler(options) val detector = FirebaseVision.getInstance() .onDeviceTextRecognizer private fun degreesToFirebaseRotation(degrees: Int): Int = when (degrees) { 0 -> FirebaseVisionImageMetadata.ROTATION_0 90 -> FirebaseVisionImageMetadata.ROTATION_90 180 -> FirebaseVisionImageMetadata.ROTATION_180 270 -> FirebaseVisionImageMetadata.ROTATION_270 else -> throw Exception("Rotation must be 0, 90, 180, or 270.") } // フレームごとに呼ばれる override fun analyze(image: ImageProxy) { // Pass image to an ML Kit Vision API doObjectClassification(image) }さらにImageAnalyzeクラスにラベリング用処理を書き足します。
認識結果が "Paper" の時のみOCR処理が走るようにします。ImageAnalyze(MainActivity.kt)// 画像分類 @SuppressLint("UnsafeExperimentalUsageError") private fun doObjectClassification(proxy: ImageProxy) { val mediaImage = proxy.image ?: return val imageRotation = degreesToFirebaseRotation(proxy.imageInfo.rotationDegrees) val image = FirebaseVisionImage.fromMediaImage(mediaImage, imageRotation) labeler.processImage(image) .addOnSuccessListener { labels -> // Task completed successfully for (label in labels) { val text = label.text Log.d(TAG, "text: $text") if (text == "Paper") { doTextRecognition(image) } else { // do something } } proxy.close() } .addOnFailureListener { e -> // Task failed with an exception Log.e(TAG, e.toString()) proxy.close() } }さらにImageAnalyzeクラスにテキスト認識用処理とパース処理を書き足して完成です。
Runしてみましょう。ImageAnalyze(MainActivity.kt)//文字認識 - 書類に書かれた文字のみ認識する private fun doTextRecognition(image: FirebaseVisionImage) { val result = detector.processImage(image) .addOnSuccessListener { firebaseVisionText -> // Task completed successfully parseResultText(firebaseVisionText) Log.d(TAG, "OCR Succeeded!") } .addOnFailureListener { e -> // Task failed with an exception Log.d(TAG, "OCR Failed...") Log.e(TAG, e.toString()) } } // パース - OCRで認識された文字列をParseする private fun parseResultText(result: FirebaseVisionText) { var resultTxtList:Array<String?> = arrayOf(null) for (block in result.textBlocks) { val blockText = block.text resultTxtList += blockText } Log.d("RESULT_TEXT",resultTxtList.toString()) listener(resultTxtList) }さいごに
CameraX + FIrebaseの組み合わせでかんたんにリアルタイム画像処理アプリが作れました。
従来のCameraライブラリよりもより楽に実装することができるので、今後も使っていきたいです。