AndroidからGoogle Sign In経由、AWS Cognitoでuser管理してDynamo DBを操作する ~AWS Java SDKを添えて~
ふと、「そういえばNo SQLのDBって業務で関わったことないな」と思いついたのでAWS DynamoDBをさわってみることにした。
Androidから直接さわるためAWS Java SDKを利用したが、ググって引っかかる情報は結構古くてもう存在しないAPIを使っていたり、公式documentのsampleコードも使えなかったりと苦労したのでメモを残しておく。
とはいえSDKのコードはgithubで公開されているし、Java docも用意されているので、これからさわる人はこっちからコードを辿ったりした方が早いかもしれない。
1.やったこと概要
- Android appからGoogle Sign In
- 1で取得したtokenを用いてAWS CognitoにOpen ID Connectでlog in
- 2で取得したcredentialを用いてAWS DynamoDBのtableをread/write
2. 手順詳細
Google Sign In を実装する
まずは画面ぽちぽちしてFirebase側の設定を終わらす。このあたりを参考にしたらできるので手順は省略。
設定が終わったらコードを書いていく。まずapp moduleのbuild.gradleにDependenciesを追加する。
implementation 'com.google.firebase:firebase-auth:19.3.0' implementation 'com.google.android.gms:play-services-auth:17.0.0'
認証画面のonCreate()
でGoogleSignInClient
を初期化。
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) .requestIdToken(getString(R.string.default_web_client_id)) .requestEmail() .build() googleSignInClient = GoogleSignIn.getClient(this, gso) }
onStart()
ですでにGoogle Log in 済み状態かどうか取得。前回のLog in情報が残っていればGoogle Sign Inはスキップ。
override fun onStart() { super.onStart() val account = GoogleSignIn.getLastSignedInAccount(this) if (account == null) { authorizeWithGoogle() } else { logInToCognito(account) } }
未Log in状態であればGoogleが用意しているLog inボタンを表示してclick listenerを設定する。
private fun authorizeWithGoogle() { setContentView(R.layout.activity_launch) findViewById<SignInButton>(R.id.sign_in_button).apply { setSize(SignInButton.SIZE_WIDE) setOnClickListener { // 第二引数のrequest code(RC_SIGN_IN)は適宜定義してください startActivityForResult(googleSignInClient.signInIntent, RC_SIGN_IN) } } }
<?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=".LaunchActivity"> <com.google.android.gms.common.SignInButton android:id="@+id/sign_in_button" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
上記のボタンが押下されるとGoogleSignIn側でいい感じにLog in処理が走ってくれるので結果を受け取るコードを書く。
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == RC_SIGN_IN) { handleSignInResult(GoogleSignIn.getSignedInAccountFromIntent(data)) } } private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) { try { val account = completedTask.getResult(ApiException::class.java) if (account != null) { logInToCognito(account) } else { // handle error case } } catch (ex: ApiException) { // handle error case } }
Open ID Connectを使用してGoogleのuser account情報を元にAWS Cognitoにsing in する
この辺りを参考にしてAWSのmanegement画面をぽちぽちしてIdentity Poolを作成する。
Cognitoの設定が終わったらコードを書く。
まずはbuild.gradleにaws sdkのdependenciesを追加
def aws_android_sdk_version = '2.15.0' implementation "com.amazonaws:aws-android-sdk-core:$aws_android_sdk_version" implementation "com.amazonaws:aws-android-sdk-ddb:$aws_android_sdk_version"
このサンプルコードでは画面間でCognitoCachingCredentialsProvider
を共有したいので、雑にApplicationクラスに持たせるようなコードを書いている。
private fun logInToCognito(account: GoogleSignInAccount) { val application = application as MyApplication application.initCredentialProvider(account.idToken) }
Applicationクラスはこんな感じ。
class MyApplication : Application() { var credentialsProvider: CognitoCachingCredentialsProvider? = null fun initCredentialProvider(token: String?) { credentialsProvider = CognitoCachingCredentialsProvider( applicationContext, YOUR_IDENTITY_POOL_ID, YOUR_REGION ).apply { logins = mapOf("accounts.google.com" to token) } } }
これでDynamo DBにアクセスするためのcredential providerの準備は完了。
Dynamo DBにアクセスする
再びAWSのmanagement画面側をぽちぽちしてDynamo DBにTableを定義する。Tableが定義されるとAmazon Resource Name (ARN)が発行されるので、CogniteでIdentity Poolを作った時に自動で作成されたIAM RoleにこのARNへのpermissionをつける。Resourceへのpermissionだけでなく、Read/WriteといったActionsへのpermissionも一緒につける。
ぽちぽちが終わったらAndroid側のコードに戻る。
Credential providerからAmazonDynamoDBClient
を生成してDynamo DBにアクセスする。ここでsetRegion()
を設定する必要があることに気がつかずしばらくはまってしまった…
val dynamoDBClient = AmazonDynamoDBClient(credentialsProvider).apply { setRegion(Region.getRegion(Regions.US_EAST_1)) // Regionは適宜置き換えてください }
ここから先実際にtableにアクセスするにはnetwork通信が走るので別threadで処理を実行する必要がある。サンプルコードでは雑にKotlin coroutine(しかも一部)で書いているので適宜使いたい非同期処理に置き換えてほしい。
まずはread系。table全体のitemsを取得したい時はこんな感じ。
withContext(Dispatchers.IO) { try { val result = dynamoDBClient.scan(ScanRequest(YOUR_TABLE_NAME)) // Do whatever you want } catch (e: NotAuthorizedException) { // We need to refresh token } catch (e: Exception) { // error handling } }
このresult
はScanResult
型になっていて、getItems()
経由で各itemのListを取得することができる。さらにこのListの型が<String, AttributeValue>
のMapとなっている。このAttributeValue
が若干曲者で、例えばNumber型が欲しい場合はgetN()
を呼ぶ必要があるが、戻り値自体はStringなのでtoInt()
を呼んだりしてキャストしてやる必要がある。詳細は下記のdoc参照。
また、これはscanに限らずtableにアクセスする場合全般に言える話だが、tokenが切れていたらNotAuthorizedException
がthrowされるので、この場合は手動でtokenをrefreshする必要がある。
条件を指定するquery()
の使い方はこんな感じ。
withContext(Dispatchers.IO) { try { val result = dynamoDBClient.query( QueryRequest(YOUR_TABLE_NAME).apply { keyConditionExpression = "id = :id" expressionAttributeValues = mapOf( ":id" to AttributeValue(credentialsProvider.identityId) ) } ) // Do whatever you want } catch (e: NotAuthorizedException) { // We need to refresh token } catch (e: Exception) { // error handling } }
上記の例ではid
というpartition keyが存在するtableに対し、ログイン中のユーザのIDに一致するitemsを取得している。
key condition expressionの記述方法はJava doc を参照。また、このdocに書いてある通り、
Use the ExpressionAttributeValues parameter to replace tokens such as :partitionval and :sortval with actual values at runtime.
とのことなので、AttributeValueでwrapしたvalueをsetExpressionAttributeValues()
で設定する必要がある。
次にWrite系。
まずはtableにitemを追加するputItem()
のメモ。
withContext(Dispatchers.IO) { try { dynamoDBClient.putItem( PutItemRequest( YOUR_TABLE_NAME, mapOf( "id" to AttributeValue(credentialsProvider.identityId), "name" to AttributeValue(account.displayName), "image_url" to AttributeValue(account.photoUrl.toString()) ) ) ) } catch (e: NotAuthorizedException) { // We need to refresh token } catch (e: Exception) { // error handling } }
追加したいItemのattributeをmapで渡すだけ。ここでもvalueはAttributeValue
でwrapする必要がある。
最後にAtomic Counterを使ってItemを更新する場合の例。
withContext(Dispatchers.IO) { try { dynamoDBClient.updateItem( UpdateItemRequest() .withTableName(YOUR_TABLE_NAME) .withKey( mapOf( "partition_key" to AttributeValue(partitionKey), "sort_key" to AttributeValue(sortKey) ) ) .apply { updateExpression = "add #count :i" expressionAttributeNames = mapOf( "#count" to "count" ) expressionAttributeValues = mapOf( ":i" to AttributeValue().apply { n = "1" } ) } ) } catch (e: NotAuthorizedException) { // We need to refresh token } catch (e: Exception) { // error handling } }
上記の例では、withKey()
でpartition keyとsort keyを渡して更新したいitemを指定し、更新の条件はapply{}
節の中に記述している。サンプルではcount
というNumber型のattributeを現在のものから1インクリメントしている。ちなみにAttributeValue
のpublic constructorはStringを取れるのだが、ここにStringである"1"を渡してしまうとString型のvalueとして処理されてしまうので、この場合はparameterなしconstructorを使ってinstanceを生成してからsetN()
でString "1"を渡す必要がある。
言いたいことは以上です。
"Hacking with iOS Online" を完走した
産休中で暇だったので、オンラインの無料iOSハンズオンコース的なやつである "Hacking with iOS Online" をコツコツ進めており、無事本日完走した✌️
よかった点
このコースには39個のプロジェクトがあり、平日一日一つずつ終わらせていった。毎日新しいプロジェクトで作るアプリも変わるので飽きずに続けられてよかった。
本来英語ネイティブの人であれば1プロジェクト1時間くらいでサクッと終わるのだろうが、自分は英語を読むのが遅いのでもっと時間がかかってしまった。でも英語の勉強も兼ねていると思えば悪くないかも。ボキャブラリーも増えた気がする。
全体として、「iOS(UIKit)でこれやろうとしたらどれくらい大変なのかな?」がちょっとだけ体感できたのでよかった。ただし、ほとんどのプロジェクトはロジックがとても少なく、大体の処理がViewControllerに入っているので設計周りの知見は全く得られなかった。(iOS devでどういった設計が流行っているのか、なんのlibraryがよく使われているのかなど。)
以下、普段Androidを書いていた自分が「iOSだとこんな簡単にできるのか!!」とびっくりしたポイントのメモを残しておく。
保育園の新園開設情報ページの更新を監視したい
産休に入って時間があるので、今まで手動でチェックしていた保育園の新園開設情報ページの更新をスクレイピングして監視することにした。1
0. 注意事項
スクレイピングに関しては実行前に一度 こちらを読むことをお勧めします。
今回自分のケースでいうと、事前に以下を確認している。
- 個人利用であること
- 週に一度アクセスするだけなので、アクセス対象に負荷をかけないこと
- アクセス対象のサイトのポリシーを確認し、問題ないこと
また、普段Androidを書いているので微妙なPythonのコードとかあるかもしれないし、AWSの各種サービスの構成も「もっとこうすれば?」みたいなのあるかもしれない。その場合はコメントで教えてください。
1. 概要
- AWS CloudWatch EventでAWS Lambdaを実行するscheduleのルールを作成
- キックされるLambda関数で自治体の新園開設情報ページを見に行き更新日時を取得
- AWS S3に前回の更新日時を置いておいて、差分があるかチェック
- (差分がある場合) S3のファイルを更新し、Slackに更新があった旨を通知
(差分がない場合) Slackに更新がなかった旨を通知2
作業の順番としては、2->3->4->1 の順で説明していく。
2. 手順詳細
自治体の新園開設情報ページを見に行き更新日時を取得
今回はPython(3.7.6)を使ってscriptを書いていく。以下使用するlibraryとその理由。
- boto3 (1.12.16)
- s3に対するAPI requestをするための公式library
- requests (2.23.0)
- BeautifulSoup (4.8.2)
- HTMLをparseしてくれる便利な子。CSS selector使えて最高っぽい。
CSS selectorについてはこちらの記事を参考にGoogle Chromeを使って簡単に任意の要素のcssを取得することができる。最高!
自分のケースだと保育園の新園開設情報ページに 更新日:20xx年y月z日
みたいな記載があるのでこの部分のCSS selectorを使用した。
コードはこんな感じ。URL等は適宜置き換えて欲しい。ローカルで試しに動かしたい人は各libraryを実行前にそれぞれpipでinstallしておいてください。
import requests from bs4 import BeautifulSoup new_open_page_url = 'page_url_to_check' new_open_soup = BeautifulSoup(requests.get(new_open_page_url).content, 'html.parser') latest_update = new_open_soup.select_one("paste_css_selector_here").text
前回の更新日時をおいておくファイルをS3に用意する
AWS S3のバケットに新しいファイルを用意する。(手順は特に説明しないので各自ググって欲しい)
このファイルに前回の更新日時を保存しておき、最新の更新日時と比較することで更新があったかどうかを判断する。
ローカルでこのPython scriptを動かしたい場合はファイルへ読み書き権限を設定したuserの AWS_ACCESS_KEY_ID
と AWS_SECRET_ACCESS_KEY
を環境変数に設定しておく。
コードはこんな感じ。これでS3に置いたファイルをローカルにdownloadできる
import boto3
s3_bucket_name = 'your_bucket_name' s3_object_key_name = 'your_object_name' download_file_path = f'/tmp/{s3_object_key_name}' s3 = boto3.resource('s3') s3.Object(s3_bucket_name, s3_object_key_name).download_file(download_file_path)
前回の更新日時と最新の更新日時を比較する
差分があった場合となかった場合にそれぞれslackに通知するmessageを設定する。また、差分があった場合のみ、ローカルに置いたファイル内容を変更する。
with open(download_file_path, 'r+') as last_update_file: last_update = last_update_file.read() if last_update == latest_update: message = 'No new info' else: message = f'<!channel> News!!!!!!! {new_open_page_url}' last_update_file.seek(0) last_update_file.truncate() last_update_file.write(latest_update)
S3のファイルを更新する
s3.Object(s3_bucket_name, s3_object_key_name).upload_file(download_file_path)
Slackに通知を飛ばす
まずはSlack appを作成する。こちらのページの左上の Create new app
のボタンを押下し、適当な名前と通知を飛ばしたいworkspaceを入力する。
appが作られたら次は OAuth & Permissions
のメニューから権限のscopeの設定を行う。今回はmessageを投げたいだけなので、 chat:write
のscopeのみ設定する。
設定が終わったら同じページの上部にある Install App to Workspace
のボタンをポチッとしてappをinstallする。確認画面に飛ぶので、workspaceとpermissionを確認して Allow
をポチる。これでOAuth Access Tokenが発行されるので、このappのWeb APIが叩けるようになる。
Tokenが発行されたのでコードに戻る。SlackにMessageを投げるAPIの仕様はこちら。このAPIは application/json
のparameterを受け付けるとある。が、注意しないといけないのは
- ここに並んでいるparameterのうちtokenだけはjson parameterではなくheaderに詰めろと書いてある
- 明示的にheaderにcontent typeでjsonを指定しろと言っている
コードはこんな感じになる。環境変数に SLACK_TOKEN
としてslackのtokenを設定しておく。
import json
slack_post_url = 'https://slack.com/api/chat.postMessage' token = os.environ['SLACK_TOKEN'] headers = { 'content-type': 'application/json', 'authorization': f'Bearer {token}' } payload = { 'channel': 'your_channel', 'text': message, } requests.post(slack_post_url, data=json.dumps(payload), headers=headers)
このコードを実行する前に、対象channelにあらかじめ作成したappをinviteしておく必要がある。これをやっておかないとapiを叩いてもerrorになる。ちなみにerrorになると言ってもstatus codeは200で返ってくるので混乱する。(messageにerrorが記述されている。)
Lambdaにscriptを登録する
今回はlibraryを使っているので、Python scriptだけでなくlibraryを含めた環境をzipに固めてLambdaにuploadする。
zipに固めてuploadする場合はhandler関数を定義しておく必要があるので、今までのコードをまるっと一つの関数にくくってしまって(ちゃんとしている人は適宜適切なサイズの責務・関数に切り出してください :pray: )、handler関数からこの関数を呼び出すようにする。
# handler関数 def lambda_handler(event, context): get_new_open_info() def get_new_open_info(): new_open_page_url = # 以下省略
zipで固める用のdirectoryを用意して、そこに各種Libraryをinstallしておく。
$ mkdir workspace $ cd workspace $ pip install beautifulsoup4 -t . // 残りのlibraryも同様にinstall
scriptをこのdirectoryにcopyするのもお忘れなく。
環境が整ったらzipに固める。
$ zip -r upload.zip *
AWS consoleで新しいlambda関数を登録する。適切な関数名・ロール等を設定し、関数を作成。
関数の設定画面でzipファイルをuploadし、handlerに scriptファイル名.handler関数名
を指定する。
関数の設定の下部の環境変数設定メニューでSlackのtokenを環境変数に設定する。(S3アクセス用の環境変数等はロールの方に設定したので今回は特に指定しない。)
また、デフォルトだとtime outの設定が3秒なのでちょっと長めに15秒くらいにした。
各種設定を変更したら設定を保存してテスト実行して問題ないことを確認する。
Cloud Watch Eventで定期実行するようにする
前項で作成したLambda関数設定ページのDesignerメニューからトリガーを追加する。トリガーには Could Watch Events/Event Bridge
を選択。
スケジュール式で週に一回実行するように指定する。注意するのは日時設定がGMTになること。日本時間で指定したい場合はJST(+0900)を考慮する必要がある。(我が家の場合は毎週日曜の朝に夫と定例会議をやるので、それに間に合うように日曜朝に実行されるように設定した。3)
cron(0 0 ? * SUN *)
cron式の構文についてはこちらの記事を参考にした。
以上。
3. おまけ
上記コードを書いた後に requests_html
というLibraryを教えてもらった。こちらはrequestsとbs4のwrapperのようなものっぽい。これからコード書くのであればbs4の代わりにこれ使えばいいと思う。
更に輪をかけて便利なソリューションが
— Shinichi Nakagawa (@shinyorke) 2020年3月5日
ウチの人に投げたら返ってきた&僕は使ったこと無いけどパット見これはとても良い.https://t.co/0XjEMii8rv
androidx.benchmarkの1.0.0がリリースされたので試してみる
Android Dev Summit 2019の下記のセッションで軽くふれられていた androidx.benchmark
の1.0.0がリリースされたようなのでさわってみます。このセッションはbenchmarkの使い方を説明したものではないけど、色々と面白いのでおすすめです。
それではやっていきましょう。
0. Reference
Jetpack Benchmarkの公式referenceはこの辺り。
API referenceはこの辺り。
で、これらをどうやって使っていくかはこの辺りにまとまっています。
1. Benchmark用のmoduleを作る
最初に紹介した動画でも言及されていたのですが、benchmarkを測る時はdebug modeをOFFにすることが奨励されています。 こういったconfigurationを他のアプリのモジュールから分離するために専用のmoduleを作ります。
Android StudioはBenchmark moduleを作るためのtemplateが用意されているのでこれを利用します。ただし、Android Studio 3.5系を使っている場合はこのtempleteを使うためには手動で下記の設定が必要です。
Help
>Edit Custom Properties
をクリック。(「idea.propertiesのファイルが今ないから作る?」って聞かれたらCreate
を選択して下さい。)- 下記を1行追加してAndroid Studioを再起動する
npw.benchmark.template.module=true
で、ここからはAndroid Studio 3.6からと共通。Templateを使ってbenchmark moduleを用意します。
- Projectを右クリックして
New
>Module
を選択 - Moduleの選択肢が出てくるので
Benchmark Module
を選んでNext
をクリック - Module名とか色々変えたかったら変更して
Finish
をクリック
これでProject rootの下に benchmark
moduleが作成されます。
このmoduleの build.gradle
をみると androidx.benchmark:benchmark
にすでに依存が付いています。ただしtemplateで指定されたversionが古いままだったりする😇1.0.0 だとpackage nameも変わっているので注意です。下記に変えてやりましょう。
androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.0.0'
また、gradleのsyncが下記のエラーで失敗したりする。
androidx.benchmark.AndroidBenchmarkRunner, in project benchmark, which is no longer valid as it has been moved to androidx.benchmark.junit4.AndroidBenchmarkRunner.
というわけで正しい依存をつけてエラーをとってやりましょう。
defaultConfig { - testInstrumentationRunner 'androidx.benchmark.AndroidBenchmarkRunner' + testInstrumentationRunner 'androidx.benchmark.junit4.AndroidBenchmarkRunner' }
また、このmoduleの androidTest
下に作られたAndroidStudioにはdebug modeをOFFにする設定がすでに記述されています。便利!
project_root/benchmark/src/androidTest/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.example.muumuu.benchmark"> <!-- Important: disable debugging for accurate performance results In a com.android.library project, this flag must be disabled from this manifest, as it is not possible to override this flag from Gradle. --> <application android:debuggable="false" tools:ignore="HardcodedDebugMode" tools:replace="android:debuggable" /> </manifest>
2. benchmarkを書いていく
BenchmarkはInstrumentation testになります。benchmarkを作るには BenchmarkRule
クラスを使っていきます。余談ですが、このクラスにはいくつかサブクラスが存在していて、Activity用のbenchmarkを測りたい時は ActivityTestRule
と ctivityScenarioRule
を使うようです。 UIのbenchmarkを測る時は @UiThreadTest
アノテーションを使います。
今回はActivityを作るのが面倒だったのでdata processingでbenchmarkを測っていきます。最近同僚に教えてもらったちょうどいい記事があるのでこれを試します。
この記事の内容を軽く紹介すると、データサイズが大きい場合に、Kotlinの Iterable
と Sequence
だとdata processのstepが2以上ある場合はSequence
使ったほうが早いよーという話です。
こんな感じで 1から1000のlistを作ってSequence
とIterable
で全く同じ操作をしてそれぞれのbenchmarkを測ります。
@RunWith(AndroidJUnit4::class) class SequenceBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() private val dataSet = (1..1000).toList() @Test fun logSequence() { benchmarkRule.measureRepeated { dataSet.asSequence() .filter { it.rem(2) == 0 } .map { it * 100 } .average() } } @Test fun logIteration() { benchmarkRule.measureRepeated { dataSet .filter { it.rem(2) == 0 } .map { it * 100 } .average() } } }
で、普通のtestを実行するようにtestを実行します。以下結果。確かにSequence
の方が早い。
Started running tests benchmark: 63,038 ns SequenceBenchmark.logSequence benchmark: 87,656 ns SequenceBenchmark.logIteration
感想
意外と簡単にシュッとできるので便利でした。Templeteがupdateされれば言うことない。
言いたいことは以上です。
ConstraintLayout 2.0でFlexboxLayoutとGridLayoutを代用する
はじめに
ConstraintLayout 2.0から導入された Flow
を使ってFlexboxLayoutやGridLayoutと同じようなことができるようになる。
こんな感じでできた。
(上がFlexboxLayoutっぽいやつ、下がGridLayoutっぽいやつ)
ConstraintLayoutは 2.0.0-beta2
を使っている。
コードサンプルはこちら
FlexboxLayoutっぽいのを作ってみる
<androidx.constraintlayout.helper.widget.Flow android:id="@+id/flexbox_flow" android:layout_width="0dp" android:layout_height="0dp" android:layout_margin="16dp" android:orientation="horizontal" app:constraint_referenced_ids="text_1,text_2,text_3,text_4,text_5" app:flow_wrapMode="chain" app:flow_horizontalStyle="packed" app:flow_verticalStyle="packed" app:flow_horizontalGap="8dp" app:flow_verticalGap="8dp" app:flow_firstHorizontalBias="0" app:flow_horizontalBias="0" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toTopOf="@id/grid_flow" />
配置したい要素となるviewとは別に Flow
を定義してやる。以下各attributeの説明
- 要素は
app:constraint_referenced_ids
でカンマ区切りで指定 - FlexboxLayoutっぽくするポイントは
app:flow_wrapMode
でchain
を指定すること app:flow_horizontalStyle
とapp:flow_verticalStyle
にpacked
を指定しているので詰めて表示される- 各要素のmerginは
app:flow_horizontalGap
とapp:flow_verticalGap
で指定 layout_constraintHorizontal_bias
などが効かないので、代わりにapp:flow_horizontalBias
を使う
GridLayoutっぽいのを作ってみる
<androidx.constraintlayout.helper.widget.Flow android:id="@+id/grid_flow" android:layout_width="0dp" android:layout_height="0dp" android:layout_margin="16dp" android:orientation="horizontal" app:constraint_referenced_ids="number_1,number_2,number_3,number_4,number_5,number_6,number_7,number_8,number_9" app:flow_wrapMode="aligned" app:flow_maxElementsWrap="3" app:flow_horizontalGap="4dp" app:flow_verticalGap="4dp" app:layout_constraintTop_toBottomOf="@id/flexbox_flow" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" />
FlexboxLayoutっぽい方とほとんど一緒だが、違いは app:flow_wrapMode
に aligned
を指定すること。あと、1行(orientationがverticalの場合は1列)にいくつ要素を配置するかを app:flow_maxElementsWrap
で指定する。
終わりに
かなり使いやすい印象なので、早くbetaが取れて欲しい。FlexboxLayoutとGridLayoutではなくてConstraintLayoutを使う理由は何かあるのかと思う人がいるかもしれないが、個人的にはこの辺りの理由でConstraintLayoutを推す。
- デザイン変更が容易にできる(各要素は変更せず、Flowだけの変更で対応できる。運がよければだけど)
- Layoutがnestしないのでパフォーマンス観点で期待できる(計測して無いですごめんなさい)
- 同じくConstraintLayout 2.0 から追加された
Layer
やCircular Reveal
などのdecoratorが使いたくなるかも?
Androidのdark theme
Androidのdark themeをさわってみたのでメモを残す。
Dark themeとは?
公式documentはこちら
電池の節約だったり、low visionなuser向けにより良いUIを提供することを目的に、黒背景のthemeがAndroid Q からサポートされた。
アプリでDark themeに対応する
App Theme
まずはApplicationのThemeのparentをDayNight
にする
<style name="AppTheme" parent="Theme.AppCompat.DayNight">
もしくはMaterialComponents
の方を使ってもいいらしい。
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight">
MaterialComponentsの方がいい感じに色をそれっぽく自動で変えてくれる気がする。
(ActionBarのcolorPrimary
の色とかbuttonとか。)
Resource
dark themeがONになっているときは基本的にvalues-night
などnightのqualifierがついたresource directoryに置いているものが読まれる。
(values-notnight
も作ることができる。defaultをdark themeにしたいときはこれを使えば良さそう。)
dark themeで変えなきゃいけないのはcolorとアイコンくらいかなぁ。
ちなみにdark themeは端末のSettings > Display > Dark Theme
からONにすることができる。 Qより前のOS(Pieとか)でも同じメニューが存在するが、ONにしても values-night
配下のリソースが使われないので注意。
Dark themeを動的に切り替える
端末のDark Themeの設定ではなく、アプリ固有でDark Themeを設定することもできる。
例えば端末の設定に関わらず、アプリ全体でDark ThemeをONにしたいときは下記のような1行を呼んでやれば良い。
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_YES)
アプリ全体ではなく、Activity単位でDark ThemeをONにしたいときは下記のような1行を呼んでやれば良い。
// Call this from AppCompatActivity
delegate.localNightMode = MODE_NIGHT_YES
引数に使える定数は以下(deprecatedなものは除く)
const | values | description |
---|---|---|
MODE_NIGHT_AUTO_BATTERY | 3 | システムの Battery Saver 機能が有効のときはdark modeを使って、それ以外はlight modeになる。 |
MODE_NIGHT_FOLLOW_SYSTEM | 0 | システムの night modeに従う。 |
MODE_NIGHT_NO | 1 | 常にlight modeを使う。また、notnight qualified resourcesが時間に関わらず(夜中でも)使われる。 |
MODE_NIGHT_UNSPECIFIED | 100 | night modeをunspecifiedしたい時に使う。主にsetLocalNightMode() でdefaultのnight modeに戻したい時に使うっぽい。 |
MODE_NIGHT_YES | 2 | 常にdark modeを使う。また、 night qualified resourcesが時間に関わらず(昼間でも)使われる。 |
おまけ
実装だけではなく、material designとしてのdark themeの考え方も読んでみると面白い。 背景(elevation = 0)は黒なんだけど、Surface (elevation = 1)は黒みがかった灰色。dark modeはelevationを影の強さで表現することができないので、高いelevationになるほど白みがかって行くことで表現しているらしい。なるほど。 material.io
Activity Shared Element TransitionでActivityが多重起動されるとAnimationがおかしくなる
はじめに
Activity Shared Element TransitionでActivityが多重起動されるとAnimationがおかしくなる現象を見つけて面白かったので調べてみた。
具体的に
まずは下記のgifを見てほしい。リストアイテム押下時にわざと1秒のdelayを入れてstartActivity()
している。
この時、Activity Shared Element Transitionするように bundle
をstartActivity()
の第2引数に渡している。
gifでやっていること手順をまとめると、
- 左上のアイテムをタップ、1秒のdelayが走る(A)
- 1のdelayが終わる前に右上のアイテムをタップ(B)
- Aのdelayが終わり、左上のタップイベントが発火、
startActivity()
が走る - Bのdelayが終わり、右上のタップイベントが発火、
startActivity()
が走る - Aのタップイベント契機のActivityが起動される
- Bのタップイベント契機のActivityが起動される
- Back keyをタップしてBのactivityがfinishされる
- Back keyをタップしてAのactivityがfinishされる
step 8のあと、A, B両方のshared itemはなぜかBのpositionに戻っていき、Aのitemがいなくなってしまうのである。
結論だけ言うと
一つのActivityに登録できるActivity Shared Element Transition用のbundleは一つだけで、しかも上書き。このようなケースを防ぎたかったらActivityの多重起動はできないようにするしかない。
何が起こったのか
何が起こったかを理解するためには、Activity Shared Element Transitionの仕組みを知らないといけない。そのためにFrameworkの内部実装を読んでいく。
このシーケンス図を見てもらえればだいたいわかるんだけど、
まずActivity起動時にstartActivity()の第2引数として渡すbundle (ActivityOptions
) は色々あって最終的にActivityStarter
がActivityRecord
を作るときに使われる。
別のactivityから戻って来たときのアニメーション用にこのoptionを取得する必要があるが、このときActivityRecord
のstatic methodであるisInStackLocked()
が使われる。
static ActivityRecord isInStackLocked(IBinder token) { final ActivityRecord r = ActivityRecord.forTokenLocked(token); return (r != null) ? r.getStack().isInStackLocked(r) : null; }
このmethodはactivityのtokenからActivityRecord
を返してくれる。そしてActivityRecord
内に格納されているActivityOptions
を使ってActivityTransitionState#setEnterActivityOptions()
がコールされる。
と言うわけでActivityOptionsとactivityのtokenは一対一で対応される。Activityが多重で起動されると起動された順番とfinishされる順番がおかしくなるとアニメーションもおかしくなる。この仕組みはアプリからは避けられないので、アプリとしてはActivityの多重起動を避けると言うアプローチがいいと思う。
おまけ
今回貼ったシーケンス図は下記のサイトで作りました。めっちゃ便利。 シーケンス図って最近エンジニアになった人には(特にweb系)馴染みのない古のツールかもしれないけど、私は好きです。
title How to handle enter transition note over User, Activity: Search Result Screen User->User:click item User->Activity:startActivityForResult() Activity->Instrumentation:execStartActivity() Instrumentation->ActivityManagerService:startActivity() ActivityManagerService->ActivityManagerService:startActivityMayWait() ActivityManagerService->ActivityStarter:setActivityOptions() note over ActivityStarter:mRequest.activityOptions = options; ActivityManagerService->ActivityStarter:execute() ActivityStarter->ActivityStarter:startActivityMayWait() ActivityStarter->ActivityStarter:startActivity() note over ActivityStarter: create ActivityRecord using activity option as a one of parameter note over User,ActivityStarter: New Activity is displayed User->User:click back key User->Activity:performStart() Activity->Activity:getActivityOptions() Activity->ActivityManagerService:getActivityOptions(mToken) note over Activity,ActivityManagerService: get acvitivy options from ActivityRecord from static method Activity<--ActivityManagerService:options Activity->ActivityTransitionState:setEnterActivityOptions()