- 投稿日:2020-12-15T23:19:31+09:00
ランダム化されたMACアドレスは社内のネットワーク認証においてはどう扱えるのか。[Cisco Identity Services Engineの利用例]
ランダム MAC アドレス
iOS 14 や Android 端末で MAC アドレスのランダム化する機能が出てきました。自宅のルータなんかで MAC アドレスによるアクセス制限をされている方たちの「なぜかつながらなくなった!」「原因がわかるまで大変だった」「ランダム化は切ったわ」というような声がちらほらとネットでも見受けられました。
Use private Wi-Fi addresses in iOS 14, iPadOS 14, and watchOS 7
https://support.apple.com/en-us/HT211227
Android Open Source Project > Develop > Connectivity > Privacy: MAC Randomization
https://source.android.com/devices/tech/connect/wifi-mac-randomization家庭や公衆 Wi-Fi 等ではプライバシーに配慮した機能として需要がある機能だとは思うのですが、MAC アドレスは端末の識別子として広く使われています。ですので、ご自宅のネットワーク環境だけでなく職場でも同様の苦労されている方がいらっしゃるんではないかと、ちょっと注目しておりました。MAC アドレスでデバイスやユーザを識別する方法というのは一般的なものなので、この機能が端末側で有効になると管理者さん的にはきついじゃないかなあと。
機種ごとに MAC アドレスのランダム化の実装はバリエーションがあるものの、一応このランダム化されたMACアドレスとは言え、フォーマットが IEEE で定められています。よって、それがランダム化された MAC アドレスなのかどうか、は少なくともシステマチックに判断できます。
この図に描いたように MAC アドレスの先頭3オクテットが OUI で、その8ビットのうちの下2桁のところで判断できます。具体的にはこの b1 が1でかつ b0 が0のアドレスがランダム化されたものとなります。ということは最初の1オクテットが2,6,A,Eのどれかで終わるものがそれです。ランダム MAC の無効化はエンドユーザにオフロードしたいです…。
こうした MAC アドレスを持ったデバイスを使っているそのエンドユーザは正当なユーザであって、登録したデバイスを利用しているのだとしたら、これを識別して、ネットワークに接続させる際の判断基準に採用できるようになるといいかなと思います。そして組織のネットワークに接続させるときにランダム化されたMACアドレスを持ったものを禁止するとか、制限付きのネットワークに入れるとかいうことも考えられますし、正規の端末を使っているのであればランダム化を切って接続しなおすようにガイドを出すというのが一番な気がしています。
とはいえその作業について管理者がいちいち面倒を見ていては業務が崩壊します。Windows端末にしてもスマホにしても、エンドユーザ側で設定変更ができるのでそうしてもらえるように一度準備しておくのが現実的かつ理想的だと思います。
例えば Windows 10 だと Wi-Fi 設定で一つクリックするだけです。
Windows 10 でのランダム MAC アドレスの設定
Cisco Identity Services Engine で ランダム MAC アドレスに対応する
Cisco Identity Services Engine(ISE) という高機能な認証サーバがあるのですが、それを使って認証フローを作るとしたら以下の様なやり方があります。
参考までにこの記事で載せているISEのバージョンは3.0です。
1. 業務用の SSID にランダム MAC を利用した端末で接続してくるとインストラクションにリダイレクト
2. そうでないものは通常の Dot1x 認証へ渡すまずはランダム MAC を使った端末が仮でストアされる内部 DB グループを作成します。 Administration > Identity Management > Groups
に進み Endpoint Identity Group に新しく入れ物を作ります。
次に Work Centers でGuest Portals を選び Create を選択し新たなポータルを作成します。これがユーザに「ランダム MAC やめなさい」的なインストラクションを見せるポータルになります。
選ぶのは Hotspot Guest Portal です。
ポータルの名前を適当に決めてて、そのままスクロールダウンしていきます。
Endpoint Identity Groupで先ほど作った Random_MAC_Endpoints を選びます。
そうしたらまた上の方にスクロールアップして戻ります。一旦ここで Save します。
Portal Page Cusomization というタブを選び、せっかくなので日本語のポータルを編集していく例にしてみましょう。
Global Page Customizations の Text Elements で適当な文章にバナーを変えます。
スクロールダウンしAcceptable Use Policy を選択し、ブラウザページタイトルや Content Title を適宜埋めます。
Instructional Text で Toggle HTML Source を押してから以下のスクリプトを記述し、それができたらまた Toggle HTML Source ボタンを押し戻します。
そうするとボタンが消えます。<script>
(function(){
jQuery('.cisco-ise-aup-text').hide();
jQuery('.cisco-ise-aup-controls').hide();
setTimeout(function(){ jQuery('#portal-session-timeout-popup-screen, #portal-session-timeout-popup-popup, #portal-session-timeout-popup').remove(); }, 100);
})();
</script>同じテキストボックスの中にランダムMACアドレスを無効化する方法を記述してユーザに案内することも併せて行うとよいと思います。
スクロールアップして Save します。ランダム MAC アドレスで接続してきたデバイスに対して与える認可プロファイルを設定します。
認可プロファイルは単純にポートの開放にあたるような Permit Access であったり、VLAN であったり ACL, そのほか先ほど設定したようなポータルへのリダイレクト等様々なものを与えることができるものです。
ということで、ここで先ほどの Random MAC Detected と名前を付けたポータルへのリダイレクトを選択します。
Policy > Policy Elements > Result > Authorizatioin > Authorization Profiles に進み、+ Add をクリックします。
Common Tasks の中でスクロールダウンしていくと Web Redirection というものが選べますので、先ほど作ったポータルを選ぶように選択します。ここで Web Redirection を選択して、次のように設定します。
スクロールダウンして Save をお忘れなく。Save ボタンとか Commit ってたまに忘れてアレってなりますよね。
ちなみにこれは無線LANコントローラ Catalyst 9800-CL 側の Redirect ACL です。
そして最後に 認証認可のポリシーを作成します。
冒頭で記述したように、ランダム MAC アドレスのフォーマットを持った端末をひっかける正規表現を使った MAB の特殊ルールを作り、先ほどのポータルに誘い込めるようにします。Policy > Policy Sets に進み、Authorization Policy に新しくルール作成します。
まとめ
これで、Random MAC を無効にする変更をしないと今作ったこっちのルールに引っ掛かってDot1x等の別のルールに進めないようにすることができるでしょう。
ISEの認証・認可で細かなルールを作ってひっかけると結構こまごまといろいろなことができて、例えば認証する SSID 名前だったり、ネットワークデバイスのロケーションだったりを条件として認可が変わる、みたいな使い方はよくお話しとしてあるのですが、今回の件で端末側のMACアドレスで正規表現を組み合わせるというあまり普段考えない方法を試すことができました。
あまり ISE3.0 独自の内容ということはないので、皆さんお手持ちのISEでお試しください。
- 投稿日:2020-12-15T23:19:31+09:00
ランダム化されたMACアドレスはネットワーク認証においてどう扱えるのか。[Cisco ISEの利用例]
ランダム MAC アドレス
iOS 14 や Android 端末で MAC アドレスのランダム化する機能が出てきました。自宅のルータなんかで MAC アドレスによるアクセス制限をされている方たちの「なぜかつながらなくなった!」「原因がわかるまで大変だった」「ランダム化は切ったわ」というような声がちらほらとネットでも見受けられました。
Use private Wi-Fi addresses in iOS 14, iPadOS 14, and watchOS 7
https://support.apple.com/en-us/HT211227
Android Open Source Project > Develop > Connectivity > Privacy: MAC Randomization
https://source.android.com/devices/tech/connect/wifi-mac-randomization家庭や公衆 Wi-Fi 等ではプライバシーに配慮した機能として需要がある機能だとは思うのですが、MAC アドレスは端末の識別子として広く使われています。ですので、ご自宅のネットワーク環境だけでなく職場でも同様の苦労をされている方がいらっしゃるんではないかと、ちょっと注目しておりました。MAC アドレスでデバイスやユーザを識別する方法というのは一般的なものなので、この機能が端末側で有効になると管理者さん的にはきついじゃないかなあと。
機種ごとに MAC アドレスのランダム化の実装はバリエーションがあるものの、このランダム化されたMACアドレスはフォーマットが IEEE で定められています。よって、それがランダム化された MAC アドレスなのかどうか、は少なくともシステマチックに判断できます。
この図に描いたように MAC アドレスの先頭3オクテットが OUI で、その8ビットのうちの下2桁のところで判断できます。具体的にはこの b1 が1でかつ b0 が0のアドレスがランダム化されたものとなります。ということは最初の1オクテットが2,6,A,Eのどれかで終わるものがそれです。ランダム MAC の無効化はエンドユーザにオフロードしたいです…。
こうした MAC アドレスを持ったデバイスを使っているそのエンドユーザは正当なユーザであって、登録したデバイスを利用しているのだとしたら、これを識別して、ネットワークに接続させる際の判断基準に採用できるようになるといいかなと思います。そして組織のネットワークに接続させるときにランダム化されたMACアドレスを持ったものを禁止するとか、制限付きのネットワークに入れるとかいうことも考えられますし、正規の端末を使っているのであればランダム化を切って接続しなおすようにガイドを出すというのが一番な気がしています。
とはいえその作業について管理者がいちいち面倒を見ていては業務が崩壊します。Windows端末にしてもスマホにしても、エンドユーザ側で設定変更ができるのでそうしてもらえるように一度準備しておくのが現実的かつ理想的だと思います。
例えば Windows 10 だと Wi-Fi 設定で一つクリックするだけです。
Windows 10 でのランダム MAC アドレスの設定
Cisco Identity Services Engine で ランダム MAC アドレスに対応する
Cisco Identity Services Engine(ISE) という高機能な認証サーバがあるのですが、それを使って認証フローを作るとしたら以下の様なやり方があります。
参考までにこの記事で載せているISEのバージョンは3.0です。
1. 業務用の SSID にランダム MAC を利用した端末で接続してくるとインストラクションにリダイレクト
2. そうでないものは通常の Dot1x 認証へ渡すまずはランダム MAC を使った端末が仮でストアされる内部 DB グループを作成します。 Administration > Identity Management > Groups
に進み Endpoint Identity Group に新しく入れ物を作ります。
次に Work Centers でGuest Portals を選び Create を選択し新たなポータルを作成します。これがユーザに「ランダム MAC やめなさい」的なインストラクションを見せるポータルになります。
選ぶのは Hotspot Guest Portal です。
ポータルの名前を適当に決めてて、そのままスクロールダウンしていきます。
Endpoint Identity Groupで先ほど作った Random_MAC_Endpoints を選びます。
そうしたらまた上の方にスクロールアップして戻ります。一旦ここで Save します。
Portal Page Cusomization というタブを選び、せっかくなので日本語のポータルを編集していく例にしてみましょう。
Global Page Customizations の Text Elements で適当な文章にバナーを変えます。
スクロールダウンしAcceptable Use Policy を選択し、ブラウザページタイトルや Content Title を適宜埋めます。
Instructional Text で Toggle HTML Source を押してから以下のスクリプトを記述し、それができたらまた Toggle HTML Source ボタンを押し戻します。
そうするとボタンが消えます。<script>
(function(){
jQuery('.cisco-ise-aup-text').hide();
jQuery('.cisco-ise-aup-controls').hide();
setTimeout(function(){ jQuery('#portal-session-timeout-popup-screen, #portal-session-timeout-popup-popup, #portal-session-timeout-popup').remove(); }, 100);
})();
</script>同じテキストボックスの中にランダムMACアドレスを無効化する方法を記述してユーザに案内することも併せて行うとよいと思います。
スクロールアップして Save します。ランダム MAC アドレスで接続してきたデバイスに対して与える認可プロファイルを設定します。
認可プロファイルは単純にポートの開放にあたるような Permit Access であったり、VLAN であったり ACL, そのほか先ほど設定したようなポータルへのリダイレクト等様々なものを与えることができるものです。
ということで、ここで先ほどの Random MAC Detected と名前を付けたポータルへのリダイレクトを選択します。
Policy > Policy Elements > Result > Authorizatioin > Authorization Profiles に進み、+ Add をクリックします。
Common Tasks の中でスクロールダウンしていくと Web Redirection というものが選べますので、先ほど作ったポータルを選ぶように選択します。ここで Web Redirection を選択して、次のように設定します。
スクロールダウンして Save をお忘れなく。Save ボタンとか Commit ってたまに忘れてアレってなりますよね。
ちなみにこれは無線LANコントローラ Catalyst 9800-CL 側の Redirect ACL です。
そして最後に 認証認可のポリシーを作成します。
冒頭で記述したように、ランダム MAC アドレスのフォーマットを持った端末をひっかける正規表現を使った MAB の特殊ルールを作り、先ほどのポータルに誘い込めるようにします。Policy > Policy Sets に進み、Authorization Policy に新しくルール作成します。
まとめ
これで、Random MAC を無効にする変更をしないと今作ったこっちのルールに引っ掛かってDot1x等の別のルールに進めないようにすることができるでしょう。
ISEの認証・認可で細かなルールを作ってひっかけると結構こまごまといろいろなことができて、例えば認証する SSID 名だったり、ネットワークデバイスのロケーションだったりを条件として認可が変わる、みたいな使い方はよくお話しとしてあるのですが、今回の件で端末側のMACアドレスで正規表現を組み合わせるというあまり普段考えない方法を試すことができました。
この正規表現での認可ルールの表現というのは、新しい ISE3.0 独自の内容ということはないので、皆さんお手持ちのISEでお試しください。
- 投稿日:2020-12-15T23:00:35+09:00
Android KotlinでMockk使ってみた
Phone Appli Advent Calender16日目です!
時間もあったし、高品質でボリューミーな記事を求められている気がしないでもないですが、
そんな幻想をぶち殺す意気込み(?)で書きます。そもそも誰で何してる人なん?
しがないAndroidエンジニア。
Androidのことならなんでもお任せ...と言いたいところですが、日々精進の毎日です。今回は、実装ばかりでテストを書かずにのうのうと生きてきた自分から生まれ変わる第一歩として、
ユニットテストで使用出来る、Mockkというライブラリを触ってみた備忘録的なことを書こうかなと思います。Mockkとは?
どこの記事にも書いてあることかもしれませんが、Kotlin用のモックライブラリです。
使用したことはないですが、Javaの場合だと、Mockitoになるのでしょうか。ワカリマセンMockですが、簡単に説明するとテスト対象以外のオブジェクトを差し替えて、どのような振る舞いをするかを
決められるオブジェクトのことです。
なので、テスト対象外のオブジェクトが存在する場合に、mockに差し替えて動作内容を定義することで、
想定しているテスト条件を簡単に作ることが出来ます。導入手順
まず、mockkを使用するためにライブラリをAndroid Projectに入れるため、以下をappのbuild.gradleに記述します。
※最新は1.10.3ですが、kotlin1.3.72だと動作しないバグあるようなので1.10.0を使用します。app/build.gradledepenadencies { ... testImplementation "io.mockk:mockk:1.10.0" }Mockkを使用するにあたって、この一行で終わりです。簡単ですね。
実際に使用してみる
テストのテの字も知らない私ですが、とりあえず使用してみます。
まずテストをするにもテスト対象のアプリがないといけませんよね。
ということで超適当なアプリを作ってみました。テスト対象アプリの作成
入力前 入力後 入力欄に名前と年齢を入れてボタンを押すと1~100のランダムな年数後の年齢が出るっていう何が楽しいのか分からないアプリです。
なんちゃってMVPで以下のようなPresenterを実装しました。MainActivityPresenter.kt.... class MainActivityPresenter( private val view: MainActivityView, private val sharedPreferencesRepository: SharedPreferencesRepository ) { // ボタンを押された際に呼ばれる fun calculateFuture(name: String, age: Int) { saveNamePreferences(name) saveAgePreferences(age) showDisplayName(name) futureAge(age) } // 未来の年齢算出 private fun futureAge(age: Int) { val addYear = (1..100).random() showDisplayFuture(addYear) showDisplayAge(age + addYear) } // View側に表示するべき年数を渡す private fun showDisplayFuture(year: Int) { view.showDisplayFuture(year) } // View側に表示するべき名前を渡す private fun showDisplayName(name: String) { view.showDisplayName(name) } // View側に表示するべき年齢を渡す private fun showDisplayAge(age: Int) { view.showDisplayAge(age) } // アプリ領域に入力した名前を保持 private fun saveNamePreferences(name: String) { sharedPreferencesRepository.setPersonName(name) } // アプリ領域に入力した年齢を保持 private fun saveAgePreferences(age: Int) { sharedPreferencesRepository.setPersonAge(age) } }テストを実装
改めて基本的にMockkはユニットテスト時に使用するもので、ユニットテストとはメソッド単体毎に実行するテストです。
今回はPresenterに対してユニットテストを作成することで、
ビジネスロジックのみでAndroid特有のクラスを考慮しなくて良いので、テストが作りやすくなります。早速テストクラスを以下のように作りました。
今回はpublicメソッドであるcalculateFutureのテストを作ってみました。import io.mockk.* import io.mockk.impl.annotations.MockK import org.junit.Before import org.junit.Test class MainActivityPresenterTest { private lateinit var presenterSpy: MainActivityPresenter // mock @MockK lateinit var mainActivityView: MainActivityView // mock @MockK lateinit var sharedPreferencesRepository: SharedPreferencesRepository private val testName = "test" private val testAge = 10 @Before fun setUp() { // アノテーションがついたオブジェクトをイニシャライズ MockKAnnotations.init(this, relaxUnitFun = true) // spykを使用すると実際のオブジェクトと混ぜることが出来る presenterSpy = spyk(MainActivityPresenter(mainActivityView, sharedPreferencesRepository), recordPrivateCalls = true) } @Test fun calculateFutureTest() { // それぞれプライベート関数が呼ばれた際に何を返すか定義 every { presenterSpy["saveNamePreferences"](testName) } returns mockk() every { presenterSpy["saveAgePreferences"](testAge) } returns mockk() every { presenterSpy["showDisplayName"](testName) } returns mockk() every { presenterSpy["futureAge"](testAge) } returns mockk() // 実際に関数を呼び出す presenterSpy.calculateFuture(testName, testAge) // それぞれ関数の中で呼び出されているかチェック verify(exactly = 1) { presenterSpy["saveNamePreferences"](testName) presenterSpy["saveAgePreferences"](testAge) presenterSpy["showDisplayName"](testName) presenterSpy["futureAge"](testAge) } } }それぞれ
@MockKアノテーションを使用し、モックオブジェクトとして作成することを宣言します。
MockKAnnotations.init(this, relaxUnitFun = true)でアノテーションが付いてるモックオブジェクトに対して、インジェクションします。
relaxUnitFun = trueを引数に渡すことによって全ての関数に対して単純な値を返すモックであることを宣言しています。
presenterSpy = spyk(MainActivityPresenter(mainActivityView, sharedPreferencesRepository), recordPrivateCalls = true)によってテスト対象のクラスオブジェクトを作成します。
recordPrivateCalls = trueを引数に渡すことによってプライベート関数をモック化できます。
今回はpublicな関数であるcalculateFutureを呼び出し、その中で指定された関数が1回づつ問題なく呼び出されているかをテストしています。
実行結果が以下です。
問題なくテストが通りました!
まとめ
今回はテスト対象の関数内で呼び出されるはずの関数が呼び出されているかを確認するテストを実装してみました。
正直こんなのテストする必要あるん?レベルですが、今回は見逃してください...
ここからPowermockとかを使ってプライベート関数もテストで実行できるようにしたりとか、値の返却値が問題なく合っているかのテストを実装していければいいなーなんて思ったり思わなかったり。テスト...頑張って書けるようになろう...
- 投稿日:2020-12-15T21:37:52+09:00
Firebase向けGoogle Analytics [仕組みと活用例]
ACCESS Advent Calendar 2020 15日目の記事になります。
はじめに
Firebase向けGoogle Analytics (Google Analytics for Firebase)はモバイル、Webアプリ向けのログ解析サービスです。
以前はFirebase Analyticsという名前でした。
本記事ではモバイル(iOS / Android)向けのログ収集について取り上げます。どのようなことが分析できるか
- どの画面がどれぐらい表示されるか (表示時間、割合)
- ある画面操作をユーザー属性別に統計を取る
- ある機能がどの程度利用されているか (操作回数、使用時間)
- ある画面にたどり着くルートのうちどちらが多いか
仕組み
iOSはCocoapods、AndroidはGradleでライブラリが提供されています。
ライブラリを使ってユーザーの属性やイベントと呼ばれるユーザー操作などのログを送信するように実装します。詳しくはFirebaseのドキュメントに記載されています。
Firebase Console
収集したデータを閲覧するサービスです。
どのような画面かはデモプロジェクトをみると雰囲気が分かります。 (Googleアカウントが必要)
iOS, Androidのアプリの両方を同じプロジェクトで管理することができます。イベント
ユーザーの操作などをイベントという形で記録できます。
例えば
screen_viewは画面が移り変わったときに記録されます。これはライブラリを導入しただけで収集されるイベントの1つです。
cf. 自動的に収集されるイベント
これはユーザーが表示した全ての画面が記録されるので、アプリの中でどの画面が最も多く表示されているかというのも分かります。デモプロジェクトのscreen_viewを見ると
ユーザーエンゲージメントの枠に画面の名前が表示されています。
iOSならViewController、AndroidならActivityがデフォルトで収集されます。
エンゲージメント率が高いと多く表示されているということになります。カスタムイベント
任意のタイミングでイベントを送信するには次のように書きます。(Android・Kotlinの場合)
Kotlinimport com.google.firebase.analytics.FirebaseAnalytics FirebaseAnalytics.getInstance(context).logEvent("tweet") { param("text_length", length) param("images", count) }paramというのはカスタムパラメータで、key-value形式で設定できます。
ただしパラメータの数は25個まで、key nameは40文字以内、英数字+アンダースコア_のみという制約があるので注意が必要です。
- FirebaseAnalytics#logEvent (Android)
- Analytics#logEvent (iOS)
ユーザープロパティ
イベントにはそのアプリのユーザー情報が紐付いて送信されています。
こちらも自動的に収集されるプロパティがあります。例えば年齢、性別、居住地域といったユーザーに関する情報や端末名、OSバージョンといった端末の情報があります。
cf. 事前定義されたユーザー ディメンション独自のプロパティを設定する
プロパティもkey-value形式で設定できます。
ただこちらは予めConsoleでキーを設定しておく必要があります。Kotlinimport com.google.firebase.analytics.FirebaseAnalytics FirebaseAnalytics.getInstance(context).setUserProperty("email", "hoge@example.com")cf. ユーザープロパティの設定と登録
ユーザープロパティはイベントのフィルターとして利用できます。
cf. レポート設定
実際の活用例
方針としてクライアント側でしか収集できないようなことをハンドリングした方がよいです。
サーバー側で解析できるようなAPIのリクエスト数やエラーを解析したい場合、Analyticsは向いていないかもしれません。またアプリのクラッシュや例外を解析するにはFirebase Crashlyticsというサービスがあるので、こちらも合わせて導入しておくと便利です。
どちらの導線が多く利用されているか調べる
例えばユーザーをブロックする機能があり、その操作をするには2つの導線があるとします。
そのどちらが多く利用されているかを調べるにはこのようにKotlin// ユーザープロフィールから firebaseAnalytics.logEvent("block_user") { param("from", "profile_view") } // ツイートから firebaseAnalytics.logEvent("block_user") { param("from", "tweet") }別の画面で同じイベントを送信するようにし、カスタムパラメータでどちらの導線から実行されたかを与えておきます。
そしてこの
fromというパラメータをConsoleで分析できるようにするために、カスタムディメンションを設定しておきます。
用途は異なりますがデモプロジェクトだと、level_completeイベントのlevel_name[post_score]が似たような結果になると思います。状態を持つ画面を区別する
例えば
TweetActivityというActivityが2つの状態を持っているとします。
デフォルトではActivityの名前が画面の名前として収集されるので、状態を判別できません。
その場合、このように手動で画面の名前を設定することで区別することができます。Kotlin// ツイート投稿画面 firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) { param(FirebaseAnalytics.Param.SCREEN_NAME, "PostTweetView") param(FirebaseAnalytics.Param.SCREEN_CLASS, "TweetActivity") } // ツイート編集画面 firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) { param(FirebaseAnalytics.Param.SCREEN_NAME, "EditTweetView") param(FirebaseAnalytics.Param.SCREEN_CLASS, "TweetActivity") }cf. 画面表示の追跡
Consoleだとscreen_viewイベントの
ユーザーエンゲージメント→スクリーン名から確認できます。利用時間を集計する
例えば通話時間を集計したい場合、通話を始めてから切るまでの時間をタイマーかなにかで測定しておいて、それをイベントのカスタムパラメータに設定しておけば集計することができます。
デモプロジェクトだとpost_scoreイベントの
scoreパラメータが該当します。
数値の単位はConsole側のカスタム指標設定で設定できます。おわりに
Firebase向けGoogle Analyticsを導入すると、機能の追加や変更をするにあたって正確なデータを元に検討することができます。
途中でイベントやユーザープロパティを実装してもそれ以前のデータは収集できないので、初期段階で実装しておくことをおすすめします。
ライブラリを入れるだけでも価値があるので、是非使ってみてください。
- 投稿日:2020-12-15T20:46:53+09:00
【Android】ConstraintLayoutの制約エラー
プログラミング勉強日記
2020年12月15日
Android StudioでConstraintLayoutの制約を設定するエラーが出たのでこのエラー内容と解決方法を示す。ConstraintLayoutの制約エラー内容
This view is not constrained. It only has designtime positions, so it will jump to (0,0) at runtime unless you add the constraints The layout editor allows you to place widgets anywhere on the canvas, and it records the current position with designtime attributes (such as layout_editor_absoluteX). These attributes are not applied at runtime, so if you push your layout on a device, the widgets may appear in a different location than shown in the editor. To fix this, make sure a widget has both horizontal and vertical constraints by dragging from the edge connections. Issue id: MissingConstraintsボタンやテキストなどのビューに対して水平方向や垂直方向の制約の定義をしないと左上の0.0の位置に配置されるエラーである。
解決方法
エラーを解決するためには、様々な方法があり、簡単にいくつか紹介する。
- ビューを画面に配置し、idとテキストを変更する
- 水平・垂直軸に制約する
- マージンとバイアスを設定する
- 制約の推論アイコンから自動で制約をする
- 制約を削除する
- 属性を手動で追加する
- ベースラインで位置揃えする
- バイアスでセンタリングする
- ガイドラインに制約する
私の場合は、垂直軸に制約することでエラーをなくすことができた。垂直軸と水平軸を制約するためには、ビューを選択して、位置揃えアイコンから設定する。
// 水平の場合 layout_constraintStart_toEndOf layout_constraintStart_toStartOf layout_constraintEnd_toStartOf layout_constraintEnd_toEndOf // 垂直の場合 layout_constraintTop_toTopOf layout_constraintTop_toBottomOf layout_constraintBottom_toTopOf layout_constraintBottom_toBottomOf変更前(activity_main.xml)<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Hello World" />変更後(activity_main.xml)<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Hello World" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" />参考文献
[Android] ConstraintLayout による制約を設定するには
Android StudioでConstraintLayoutの制約を設定する方法を配置パターン別に解説
- 投稿日:2020-12-15T19:46:30+09:00
【Android】Socketを使ってローカルで通信してみる
はじめに
Life is Tech! Tokai Advent Calendar 2020の17日目を担当します,Androidメンターのあみだです!
今回はAndroidでSocket通信をする話です.
最近はクラウドを経由してデータをやりとりすることが多く,わざわざSocketを使って通信することは滅多にないと思いますが,クラウド上にデータを上げたくない場合や直接通信がしたい場合などのレアケースにおいては使うことになるかと思います.
では,さっそく本題に入っていきましょう!Socket通信とは
ざっくりと説明すると,サーバとクライアントが通信するために接続口(Socket)を作ってやりとりをする方式です.
基本的にはサーバ側が通信を行うポート番号を指定して待機し,クライアント側がサーバのIPアドレスとポート番号を元に接続しに行くことで,二者間で用いるSocketを作成します.
Socketに対してデータを流し込むことで送信を行い,流し込まれたデータを受け取ることで受信を行います.このSocketですが,実はBluetoothに対しても用意されており,Socketの作成以降は同様に扱えます.
なので今回はWi-Fi経由とBluetooth経由の両方でSocketを作成して通信したいと思います.実装
全体はGithubを参考にしてください.
またこのコードはBluetoothとWi-Fiのモジュールが搭載されていることを前提に書いているため,モジュール非搭載の端末やエミュレータで起動すると落ちます.
実機であればほぼ問題ないと思いますが,エミュレータはBluetoothに対応していないことが多いので要注意です.前提
BluetoothとWi-Fiを使うために以下のパーミッションが必要になります.
AndroidManifest.xml<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />また通信処理はメインスレッドでは行えないため,Coroutineを使って別スレッドで行います.
そのためgradleにimplementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-rc01'を追加しています.MainActivity
MainActivityでは通信のに用いるインターフェースが有効かどうか,またWi-Fiであれば接続されているかを確認しています.
もしどこかに不備があればそれを表示し,サーバ画面とクライアント画面に遷移するためのボタンを無効化します.
クラス外に定義してある定数は後ほど使います.MainActivity.ktconst val STR_UUID: String = "5E7B99D0-F404-4425-8125-98A2265B4333" const val PORT: Int = 55913 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val parent: ConstraintLayout = findViewById(R.id.constraint_layout) val buttonServer: Button = findViewById(R.id.button_server) val buttonClient: Button = findViewById(R.id.button_client) val btAdapter: BluetoothAdapter = BluetoothAdapter.getDefaultAdapter() val wifiManager: WifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager var checkIf: Boolean = true if(!btAdapter.isEnabled){ Snackbar.make(parent, "Bluetoothが無効", Snackbar.LENGTH_SHORT).show() checkIf = false } if(!wifiManager.isWifiEnabled){ Snackbar.make(parent, "Wi-Fiが無効", Snackbar.LENGTH_SHORT).show() checkIf = false } if(wifiManager.connectionInfo.ipAddress == 0){ Snackbar.make(parent, "IPアドレスが無効", Snackbar.LENGTH_SHORT).show() checkIf = false } if(!checkIf) { buttonServer.isEnabled = false buttonClient.isEnabled = false } buttonServer.setOnClickListener { val intent: Intent = Intent(this, ServerActivity::class.java) startActivity(intent) } buttonClient.setOnClickListener { val intent: Intent = Intent(this, ClientActivity::class.java) startActivity(intent) } } }ServerActivity
サーバ側のActivityです.メッセージを受信したらTextViewに表示します.
Wi-Fi経由とBluetooth経由で受信待機を行う関数を,それぞれ別のスレッドで行っています.
Coroutineに関してはイマイチしっかりと理解しきれていないので,間違った使い方であればご指摘いただきたいです.
またWifiManagerから取得できるIPアドレスはInt型なので,ipToStringにて分かりやすい表記に変換しています.
この処理はこちらを参考にしています.ServerActivity.ktclass ServerActivity : AppCompatActivity() { var textMessage: TextView? = null var btSrvSoc: BluetoothServerSocket? = null var btSoc: BluetoothSocket? = null var btDis: DataInputStream? = null var ipSrvSoc: ServerSocket? = null var ipSoc: Socket? = null var ipDis: DataInputStream? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_server) textMessage = findViewById(R.id.text_message) val textIp: TextView = findViewById(R.id.text_ip) textIp.text = ipToString( (applicationContext.getSystemService(WIFI_SERVICE) as WifiManager).connectionInfo.ipAddress ) lifecycleScope.launchWhenResumed { startBtSrv() } lifecycleScope.launchWhenResumed { startIpSrv() } } private suspend fun startBtSrv() = withContext(Dispatchers.IO) { try { val btAdapter: BluetoothAdapter = BluetoothAdapter.getDefaultAdapter() btSrvSoc = btAdapter.listenUsingRfcommWithServiceRecord("BtIpComm", UUID.fromString(STR_UUID)) while (true) { btSoc = btSrvSoc?.accept() btDis = DataInputStream(BufferedInputStream(btSoc?.inputStream)) try { while (true) { val msg = btDis?.readUTF() withContext(Dispatchers.Main) { textMessage?.text = "$msg from Bluetooth" } } } catch (e: Exception) { } finally { btDis?.close() btSoc?.close() } } }catch (e: Exception){} } private suspend fun startIpSrv() = withContext(Dispatchers.IO){ try { ipSrvSoc = ServerSocket(PORT) ipSrvSoc?.reuseAddress = true while (true) { ipSoc = ipSrvSoc?.accept() ipDis = DataInputStream(BufferedInputStream(ipSoc?.inputStream)) try { while (true) { val msg = ipDis?.readUTF() withContext(Dispatchers.Main) { textMessage?.text = "$msg from Wi-Fi" } } } catch (e: Exception) { } finally { ipDis?.close() ipSoc?.close() } } }catch (e: Exception){} } private fun ipToString(i: Int): String { return "${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}" } override fun onDestroy() { super.onDestroy() try { btDis?.close() btSoc?.close() btSrvSoc?.close() ipDis?.close() ipSoc?.close() ipSrvSoc?.close() }catch (e: Exception){} } }受信待機関数内では,はじめに待受用のSocketを作成します.
Wi-Fi経由であれば使用するポート番号を,Bluetooth経由であれば使用するUUIDを指定します.
どちらも既に用途が決まっている値以外であれば任意に設定することが出来ますが,サーバ側・クライアント側の両方で一致させる必要があります.最も外側のWhileループではクライアント側の接続を待機しています.
接続された場合は入力用の口であるInputStreamを取得します.
このInputStreamはバイトしか扱えないため,そのままであれば受け取ったバイトを変換していく必要があります.
しかしそれではあまりに面倒なので,BuffInputStreamでバッファリングし,プリミティブ型で扱えるようにするDataInputStreamでラップします.次のWhileループではメッセージを待機しています.
今回はString型のメッセージを受信する予定なのでreadUTFを利用しています.
メッセージが来た場合はそれを変数に代入してTextViewに表示しています.
表示まで実行したところでまたメッセージ待ちの状態に戻り,接続が維持されている限りひたすらに受信を行い続けます.
クライアント側から接続が切断された場合にはreadUTFが例外を発生させますが,try-catchによって例外が処理されループが終了します.
あまりよろしくないですが,今回は例外に対応しないので握りつぶしています.メッセージ待機のループから抜けると最も外側のWhileループによって接続待機がはじまるので,このActivityが開かれている間は接続待機とメッセージ待機を繰り返します.
ClientActivity
クライアント側のActivityです.接続先を設定して送信ボタンを押すことでEditTextの内容が送信されます.
今回は色々と手を抜いて簡単化のためにBluetooth通信を行う端末をペアリング済みのものから取得するようにしていますが,BluetoothDeviceさえ取得できれば良いので機器探索を行ってもよいと思います.
またDialogFragmentではなくAlertDialogを利用しているため,画面回転などによって落ちます.きちんと作るのであればDialogFragmentを利用しましょう.ClientActivity.ktclass ClientActivity : AppCompatActivity() { var btSoc: BluetoothSocket? = null var btDos: DataOutputStream? = null var ipSoc: Socket? = null var ipDos: DataOutputStream? = null var btDevice: BluetoothDevice? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_client) val btAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() val btDeviceList: List<BluetoothDevice>? = btAdapter?.bondedDevices?.toList() val deviceNameList:MutableList<String> = mutableListOf() val parent: ConstraintLayout = findViewById(R.id.layout_client) val editMessage: EditText = findViewById(R.id.edit_message) val buttonSelectBtDevice: Button = findViewById(R.id.button_select_bt_device) val buttonSendBt: Button = findViewById(R.id.button_send_bt) val editIpAddr: EditText = findViewById(R.id.edit_ip_addr) val buttonSendIp: Button = findViewById(R.id.button_send_ip) btDeviceList?.forEach { deviceNameList.add(it.name) } buttonSelectBtDevice.setOnClickListener { try{ btDos?.close() btSoc?.close() }catch (e: Exception){} btSoc = null AlertDialog.Builder(this) .setTitle("接続デバイスを選択") .setItems(deviceNameList.toTypedArray()) { _, which -> btDevice = btDeviceList?.get(which) buttonSelectBtDevice.text = deviceNameList[which] } .show() } editIpAddr.doOnTextChanged { _, _, _, _ -> try { ipDos?.close() ipSoc?.close() }catch (e: Exception){} ipSoc = null } buttonSendBt.setOnClickListener { if(btDevice == null){ Snackbar.make(parent, "接続機器を選択してください", Snackbar.LENGTH_SHORT).show() return@setOnClickListener } lifecycleScope.launchWhenResumed { withContext(Dispatchers.IO) { try{ if(btSoc == null) { btSoc = btDevice?.createRfcommSocketToServiceRecord(UUID.fromString(STR_UUID)) btSoc?.connect() btDos = DataOutputStream(BufferedOutputStream(btSoc?.outputStream)) } btDos?.writeUTF(editMessage.text.toString()) btDos?.flush() }catch (e: Exception){ try{ btDos?.close() btSoc?.close() }catch (e: Exception){} btDos = null btSoc = null withContext(Dispatchers.Main) { Snackbar.make(parent, "Bluetoothでの通信に失敗", Snackbar.LENGTH_SHORT).show() } } } } } buttonSendIp.setOnClickListener { lifecycleScope.launchWhenResumed { withContext(Dispatchers.IO) { try { if(ipSoc == null){ ipSoc = Socket(editIpAddr.text.toString(), PORT) ipDos = DataOutputStream(BufferedOutputStream(ipSoc?.outputStream)) } ipDos?.writeUTF(editMessage.text.toString()) ipDos?.flush() }catch (e: Exception){ try{ ipDos?.close() ipSoc?.close() }catch (e: Exception){} ipSoc = null withContext(Dispatchers.Main) { Snackbar.make(parent, "Wi-Fiでの通信に失敗", Snackbar.LENGTH_SHORT).show() } } } } } } override fun onDestroy() { super.onDestroy() try { ipDos?.close() ipSoc?.close() btDos?.close() btSoc?.close() }catch (e: Exception){} } }送信ボタンを押した際にSocketがnullであれば未接続であると判断して接続処理を行うため,Bluetooth端末を選択する際や,宛先IPアドレスを入力する際に既存のSocketを閉じてnullを代入しています.
クライアントよりも先にサーバ側が接続を終了していた場合などでは送信時に例外が発生するので,ソケットを閉じてnullを代入し,通信失敗を表示します.実行
2台の端末間で通信が行えることを確認します!
左側のタブレットがクライアント,右側のスマホがサーバです.まずはBluetoothでの通信を試します.
宛先BTデバイスを選択と書いてあるボタンをタップするとダイアログが出ます.スマホ(moto g8)を選択してメッセージを入力し,送信ボタンをタップします.
するとサーバ側にメッセージが表示されました.次にWi-Fiでの通信を試します.
宛先IPアドレスを入力とあるEditTextにサーバのIPアドレスを入力し,メッセージを入力して送信ボタンをタップします.
するとサーバ側にメッセージが表示されました.というわけで,無事に通信が行えることが確認できました!
おわりに
例外処理などが少々面倒ではありますが,ローカルでの通信によりデータのやりとりが行えました!
今回はWi-Fi経由とBluetooth経由の両方でメッセージの送受信を行いましたが,どちらか一方で十分な場合や,やりとりするメッセージの順番が決まっており無限ループが不要な場合もあると思います.その場合は適宜書き換えてください.またサーバに対して複数台のクライアントが接続できるような変更や,例えばBluetoothでIPアドレスや鍵交換など通信に必要な準備を行い,Wi-Fi経由でメッセージのやりとりを行うような通信経路の使い分けを行っても面白いと思います.
Life is Tech ! #1 Advent Calendar 2020に投稿したAndroid Thingsの記事に続いて需要がニッチな感はありますが,必要としている誰かの参考になれば幸いです!
参考文献
ソケット(Wikipedia)
AndroidでKotlin Coroutinesを使ってソケット通信をしてみる(Qiita)
Socket(Android Developer)
WifiManager(Android Developer)
Bluetooth の概要(Android Developer)
BluetoothAdapter(Android Developer)
- 投稿日:2020-12-15T18:06:16+09:00
FlutterのAndroid開発における64K問題の解決方法
TL; DR
Androidでは、アプリおよびアプリが参照するライブラリが65,536メソッドを超えるとビルドエラーが発生します。
65,536 = 64 × 1,024(K)なので64K問題と言います。解決策1: アプリが使用するメソッドやライブラリを見直す
一番シンプルで当たり前の解決策。とはいえ、必要だからライブラリを入れているのであって、実際に効果はそこまで期待できないと思われます。
実際は、解決策2, 3のどちらかを選択することになるでしょう。解決策2: 対象のSDKを21以上にする
Android 5.0(API 21)移行ではデフォルトでmultidexに対応しているので、何らかの理由でそれ以前のバージョンに対応する必要がなければ単純に対象のSDKを21以上にするのが良いです。
android/app/build.gradleandroid { defaultConfig { applicationId "com.example.sample" // minSdkVersion 16 minSdkVersion 21 // 最小サポートSDKを21以上にする targetSdkVersion 29 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } }解決策3: multidexサポートライブラリを使用する
何らかの事情がある場合にはmultidexサポートライブラリを使用することでも解決できます。
android/app/build.gradleandroid { defaultConfig { applicationId "com.example.sample" minSdkVersion 16 targetSdkVersion 29 versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true // multidexサポートライブラリを使用する } }解決策4: ビルド時に圧縮する(参考)
Flutterでできるのかは要調査(調査するとは言っていない)。
ビルド時に不要なメソッドを除くことができるはず。検索用
1.
flutter runを実行した際に64K問題が発生したときのログこの時は、
cloud_firestoreをpubspec.yamlに追加したことでアプリ全体のメソッド数が85242個と64Kよりも大きくなってしまった。D8: Cannot fit requested classes in a single dex file (# methods: 85242 > 65536) com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: The number of method references in a .dex file cannot exceed 64K. Learn how to resolve this issue at https://developer.android.com/tools/building/multidex.html参考資料
- 投稿日:2020-12-15T10:58:46+09:00
AndroidライブラリでBill of Materials (BOM) を使ってみよう
Bill of Materials (BOM) とは?
プロジェクトで利用するライブラリーのバージョンを指定して同期させるMavenの仕組み
つまり?
BOMを使うと、最適な依存ライブラリのバージョンを自動指定してくれる!
なにがお得?
- バージョンを個別に指定する記載が不要になり、build.gradleがスッキリする
- ライブラリの特定バージョンの組み合わせによる不具合がなくなる
※ ちなみに、Bill of Materials を直訳すると「部品表」という意味
MavenのBOMドキュメント
http://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html#bill-of-materials-bom-pomsBOM List
Android開発で使えそうなBOMを集めてみた
Android
Firebase-BOM
https://maven.google.com/web/index.html?authuser=0&q=bom#com.google.firebase:firebase-bomKotlin Libraries Bill of Materials
https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-bomKotlinx Coroutines BOM
https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-bomオマケ
Spring Framework BOM
https://mvnrepository.com/artifact/org.springframework/spring-framework-bomAWS SDK For Java
https://mvnrepository.com/artifact/com.amazonaws/aws-java-sdk-pomJUnit 5
https://mvnrepository.com/artifact/org.junit/junit-bom依存関係を実際に確認してみよう
空のAndroidプロジェクトを作成
BOMライブラリをdependenciesに追加
dependencies tree を眺めて差分を確認してみる./gradlew app:dependencies --configuration debugCompileClasspath | grep coroutine今回は coroutine android を使用する
https://github.com/Kotlin/kotlinx.coroutines#androidTODO: コードを貼る
BOMを有効活用しよう
BOMについてある程度理解はできた
さっそく、プロジェクトに導入いきましょう!
ここから妄想
社内専用のBOMを作ってみてはどうだろう?
例えば新規Androidプロジェクトを作る時に
「okhttpとretrofitとcoroutineを追加して〜」
これが「このBOM1つで解決やで!」
と、ならないだろうかメリット
- ライブラリ選定が楽(同じライブラリしか使わない場合)
- プロジェクトの初期構築がスピードアップする
デメリット
- 社内にBOM環境を作る必要がある(GitHubのみでOK?)
- BOMバージョンのメンテナンスが大変そう
うーん、ちょっと考えてみたけど、長く使うとなればメンテのコストが大きい。
毎週のように新規プロジェクトが作成される状況や、自社でライブラリを作成/配布している場合には有効かも!
- 投稿日:2020-12-15T10:08:59+09:00
KtLint + Spotless + GitHub ActionsでPRにsuggested changeさせる
ちょっと30分ぐらいで書いた小ネタで申し訳ないんですが、すごく簡単で、便利なので、アドベントカレンダーで紹介します。
Android Studioの自動フォーマットだとKtLintで指摘されるものを修正できず、Formatterをコミット前やビルド時に走らせるのもコード量に比例して遅くなりそうで、また変更したところだけフォーマットさせたいですがうまくできません。コミットのたびに時間かかりそうで微妙で、なにか解決策を探していました。
これを調べ始めて30分程度でできちゃったので、すごく簡単に機械的にレビューさせられるので、ちょっと試してみてください。使うツール
Spotless
変更したファイルを検出してFormatterを呼び出してくれたり、いろいろな機能があります。(JetNewsなどGoogleのOSSなどでも使われています。)KtLint
Kotlinでよく使われるFormatterです。Spotlessと連携して動作させることができます。reviewdog
PRにレビューコメントをしてくれるツールです。最近、git diffで変更が出ている部分をSuggested Changeでレビューできる機能が追加されました。この3つを連携させて変更したファイルをフォーマットさせ、変更をsugggested changeさせることができます。
手順
まずSpotless + KtLintを導入します。
plugins { id 'com.diffplug.spotless' version '5.7.0' } allprojects { repositories { ... } } subprojects { repositories { google() jcenter() } apply plugin: 'com.diffplug.spotless' spotless { kotlin { target '**/*.kt' targetExclude("$buildDir/**/*.kt") targetExclude('bin/**/*.kt') ktlint("0.39.0") } } }あとは以下を
.github/workflows/review-suggest.ymlなどに保存します。
GITHUB_TOKENは勝手にGitHub Actionsが提供しているので、他にすることはないです。
ここではreviewdog/action-suggesterを使っています。name: reviewdog-suggester on: [pull_request] jobs: kotlin: name: runner / suggester / spotless runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-java@v1 # JDK 11が必要なければ消してください with: java-version: '11' - run: ./gradlew spotlessKotlinApply - uses: reviewdog/action-suggester@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} tool_name: spotless以下でフォーマットをかけて
./gradlew spotlessKotlinApply以下でreviewdogを使って差分をコメントさせるだけです。つまり差分さえあればレビューさせられるので、いろんなフォーマッターに使えて便利です。
uses: reviewdog/action-suggester@v1これでコメントさせていくことができました。 GitHub Actionsでreviewdogを使うことに対してセキュリティなど少し気になるところはあるのですが、reviewdogの作者の方は以前にGoogleにはいるブログなどを書いていたりして、割と身元が分かっている方なので安心して使うことができます。
ローカルでコミット前にフォーマットするには?
何も考えずに
./gradlew spotlessApplyだけだと全部のソースコードをフォーマットかけてしまいます。spotlessにはratchetという便利機能があり、以下のようにratchetFromでブランチを指定しておいて、
./gradlew spotlessApplyすると、masterブランチからの差分だけフォーマットをかけることができます。便利です。spotless { ratchetFrom 'origin/master'ただしこの機能をGitHub Actionsでも使う形になるので、yamlファイルにcheckoutを追加して一度masterをチェックアウトしてから必要なブランチにチェックアウトして利用してく必要があります。これをしないとorigin/masterが存在しないという形でビルドが失敗します。
- uses: actions/checkout@v2 with: ref: master # 一度masterでcheckoutする - uses: actions/checkout@v2 # 目的のブランチでcheckoutする
- 投稿日:2020-12-15T09:42:56+09:00
dumpsysを使ってみる
はじめに
先日、とある調査の中でdumpsysが便利であることに改めて気づいたため、覚えておくと役に立つ(かもしれない)dumpsysの使い方をまとめました。
検証環境
- Android Studio4.1.1
- Android 10
- ターミナル上でadbにPATHが通っている前提
dumpsysの基本
実機やエミュレーターが接続されている状態で、ターミナル上で以下のコマンドを叩くと端末からあらゆる情報がdumpされて標準出力に表示されます。
adb shell dumpsysこのようにdumpがひたすら出力されます。この中から必要なところだけを絞り込んで使っていくのが基本的な使い方です。
また、以下のようにパラメーターを渡すことで特定のサービスに絞って情報を出力することが出来ます。
(実行中のサービス一覧はadb shell dumpsys -lで確認できる)adb shell dumpsys activity # activityに関連する情報だけ(標準出力) ACTIVITY MANAGER SETTINGS (dumpsys activity settings) activity_manager_constants: max_cached_processes=32 background_settle_time=60000 fgservice_min_shown_time=2000 fgservice_min_report_time=3000 fgservice_screen_on_before_time=1000 fgservice_screen_on_after_time=5000 content_provider_retain_time=20000 gc_timeout=5000 gc_min_interval=60000なお、dumpsysの出力の中に
ACTIVITY MANAGER SETTINGS (dumpsys activity settings) activity_manager_constants
や
ACTIVITY MANAGER RECENT TASKS (dumpsys activity recents)
のように、(dumpsys foo bar)みたいなセクションがあります。
括弧内の通りdumpsysコマンドを叩くと、そのセクションだけ出力することが出来ます。adb shell dumpsys activity recents(標準出力) ACTIVITY MANAGER RECENT TASKS (dumpsys activity recents) # ←このセクション配下だけ出力される mRecentsUid=10090 mRecentsComponent=ComponentInfo{com.google.android.apps.nexuslauncher/com.android.quickstep.RecentsActivity} mFreezeTaskListReordering=false mFreezeTaskListReorderingPendingTimeout=false Recent tasks: * Recent #0: TaskRecord{4adb8ab #8 A=net.fdash.adventcalendar2020 U=0 StackId=3 sz=3} userId=0 effectiveUid=u0a134 mCallingUid=2000 mUserSetupComplete=true mCallingPackage=null affinity=net.fdash.adventcalendar2020 intent={act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10000000 cmp=net.fdash.adventcalendar2020/.Activity1} mActivityComponent=net.fdash.adventcalendar2020/.Activity1 autoRemoveRecents=false isPersistable=true numFullscreen=3 activityType=1 rootWasReset=false mNeverRelinquishIdentity=true mReuseTask=false mLockTaskAuth=LOCK_TASK_AUTH_PINNABLE Activities=[ActivityRecord{cdc0f28 u0 net.fdash.adventcalendar2020/.Activity1 t8}, ActivityRecord{7217a91 u0 net.fdash.adventcalendar2020/.Activity2 t8}, ActivityRecord{160eae7 u0 net.fdash.adventcalendar2020/.Activity3 t8}] askedCompatMode=false inRecents=true isAvailable=true mRootProcess=ProcessRecord{b7deb08 11683:net.fdash.adventcalendar2020/u0a134} stackId=3 hasBeenVisible=true mResizeMode=RESIZE_MODE_RESIZEABLE_VIA_SDK_VERSION mSupportsPictureInPicture=false isResizeable=true lastActiveTime=8206625 (inactive for 1007s) * Recent #1: TaskRecord{f01673 #5 I=com.google.android.apps.nexuslauncher/.NexusLauncherActivity U=0 StackId=0 sz=1} userId=0 effectiveUid=u0a90 mCallingUid=0 mUserSetupComplete=true mCallingPackage=null intent={act=android.intent.action.MAIN cat=[android.intent.category.HOME] flg=0x10000100 //(後略)ちなみに、以下のように
-hオプションを付けることでも、各サービスが取れるパラメーターを確認することが出来ます。adb shell dumpsys activity -h(標準出力) Activity manager dump options: [-a] [-c] [-p PACKAGE] [-h] [WHAT] ... WHAT may be one of: a[ctivities]: activity stack state r[recents]: recent activities state b[roadcasts] [PACKAGE_NAME] [history [-s]]: broadcast state broadcast-stats [PACKAGE_NAME]: aggregated broadcast statistics i[ntents] [PACKAGE_NAME]: pending intent state p[rocesses] [PACKAGE_NAME]: process state o[om]: out of memory management perm[issions]: URI permission grant state prov[iders] [COMP_SPEC ...]: content provider state provider [COMP_SPEC]: provider client-side state s[ervices] [COMP_SPEC ...]: service state allowed-associations: current package association restrictions as[sociations]: tracked app associations lmk: stats on low memory killer lru: raw LRU process list binder-proxies: stats on binder objects and IPCs settings: currently applied config settings service [COMP_SPEC]: service client-side state package [PACKAGE_NAME]: all state related to given package all: dump all activities top: dump the top activity WHAT may also be a COMP_SPEC to dump activities. COMP_SPEC may be a component name (com.foo/.myApp), a partial substring in a component name, a hex object identifier. -a: include all available server state. -c: include client state. -p: limit output to given package. --checkin: output checkin format, resetting data. --C: output checkin format, not resetting data. --proto: output dump in protocol buffer format. --autofill: dump just the autofill-related state of an activityこれさえ押さえておけば、あとは各自がお好みのshell芸を披露するだけです
adb shell dumpsys activity top | perl -nle 'BEGIN{$s=999} /^(\s*)View Hierarchy|^(\s*)/; $1 ? (print and $s=length($1)) : length($2)>$s ? print : ($s=999)'Activityのスタックを調べる
以下のコマンドで、Activityのスタックを調べることが出来ます。
adb shell dumpsys activity activitiesスタックごとに上(前面)から下(背面)の順でActivityの状態や起動時のIntentが詳細に出力されます。この中から自分のアプリを見つけそのブロックを上から下に見ていくことで、自分のアプリのスタック状態を遡ることができます。
「このActivityって今STOPPEDなんだっけ?それともDESTROYEDになっちゃってる?」といったことも確認できます。また、
Running activities (most recent first):という部分を見れば、生きているActivityのスタックが一目で確認できます。Running activities (most recent first): TaskRecord{28b4600 #15 A=net.fdash.adventcalendar2020 U=0 StackId=10 sz=3} Run #2: ActivityRecord{980e13b u0 net.fdash.adventcalendar2020/.Activity3 t15} Run #1: ActivityRecord{b3a081a u0 net.fdash.adventcalendar2020/.Activity2 t15} Run #0: ActivityRecord{c9622b9 u0 net.fdash.adventcalendar2020/.Activity1 t15※ただし、メモリ不足などでActivityが開放されちゃっている場合は、Runnning activitiesのところに出てこないため注意が必要です。その場合は、前述の
Stack #~を一つずつ確認する必要があります。お手軽に一覧を出すならこれでも良いでしょう
adb shell dumpsys activity activities | grep "Run #"(標準出力) Run #2: ActivityRecord{980e13b u0 net.fdash.adventcalendar2020/.Activity3 t15} Run #1: ActivityRecord{b3a081a u0 net.fdash.adventcalendar2020/.Activity2 t15} Run #0: ActivityRecord{c9622b9 u0 net.fdash.adventcalendar2020/.Activity1 t15Fragmentの一覧を見る
各タスクのトップにあるActivityのdumpから、それぞれのActivityが管理しているFragmentを確認することも出来ます。
adb shell dumpsys activity topFragmentのスタックを確認する際は
Added Fragments:の部分を確認すれば一目で分かります。(長すぎて上のスクショからは見切れています)Added Fragments: #0: NavHostFragment{4f9705c} (73f6462a-a38e-4da8-bbf7-f1a214a16698) id=0x7f0800f2} #1: TestFragment1{6df4cc4} (34825848-e506-48b1-a4f3-111b239439c9) id=0x7f0800f2} #2: TestFragment2{1bb01a9} (7dbfcc78-5d82-4e85-aba3-d1ffa2a97ee3) id=0x7f0800f2} #3: TestFragment2{2903ce1} (07b12688-8098-4198-bbf6-358682ccd2c6) id=0x7f0800f2}↑のケースだと、
TestFragment2が予期せず2つ張り付いているかも?ということに気付くことができます。(FragmentTransaction#replaceすべきところでFragmentTransaction#addしてしまった時にありがち)もちろんActivityと同じように、それぞれのFragmentのstateやdetach済みかどうかを個別に確認することもできます。
Viewの状態を調べる
Fragmentの時と同じく、現在のActivityのdumpからviewの状態を調べることもできます
adb shell dumpsys activity top上記コマンドを叩いた時に、
View Hierarchy:という部分でVIewの階層構造や、それぞれのVIewの状態を取得することができます。Viewの階層は
uiautomatorviewerでも検証できますが、uiautomatorviewerで見つけられないView.GONEやView.INVISIBLEなViewが見れたり、viewのidで検索が出来たりするところがdumpsysの良いところです。ちなみに
この部分のフラグが何を意味しているかは、
String#toStringの実装1 を見ると確認出来ます。↑の場合だと
@1daace0のFAB(viewのidはid/invisible_fab)がVisibilityがINVISIBLEだけどフォーカスが当たる状態になっていて、ViewがEnabled、DrawMaskにはなっていなくて...といった具合です。(余談)
前述のshell芸を使うと、この
View Hierarchyブロックだけフィルタリングして出力することが出来ます# (再掲) adb shell dumpsys activity top | perl -nle 'BEGIN{$s=999} /^(\s*)View Hierarchy|^(\s*)/; $1 ? (print and $s=length($1)) : length($2)>$s ? print : ($s=999)'まとめ
これ以外にも数えきれないほど沢山の情報を取得することができるので、改めてもう一度
adb shell dumpsysと叩いて情報量の多さを体感してみてください。
普段はAndroidStudio付属のデバッグツールを使ってデバッグすることの方が多いと思いますが、検証で行き詰まってどうしようもなくなった時には、このdumpsysの存在を思い出して頂ければと思います。また、dumpsysはコマンドラインベースならではの取り回しの良さもあるので、黒い画面厨の方は是非他のツールと組み合わせて便利な使い方を探してみると新たな発見があると思います。(例えば、dumpsysの出力をpeco に食わせると、対象のviewの状態を瞬時に確認することが出来たりします)
dumpsysの出力をpecoに渡して、viewのidで対象のview(
id/invisible_fab)を絞り込む様子
現場からは以上です。
明日は、@k-sekido さんの「エンジニア提案をしよう」です。お楽しみに!!
参考
https://developer.android.com/studio/command-line/dumpsys?hl=ja
- 投稿日:2020-12-15T01:41:11+09:00
[Android]中央でピタッととまるカルーセルの作り方
中央でピタッととまるカルーセルの作り方の解説します。ハマりどころがあるので記事にしました。
実装手順
Step 1. 依存ライブラリの記述
RecyclerViewとGravitySnapHelperというライブラリを使います。build.gradleに以下を記述します。
app/build.gradleimplementation 'androidx.recyclerview:recyclerview:1.1.0' implementation 'com.github.rubensousa:gravitysnaphelper:2.2.1'Step 2. 水平方向にスクロール可能なRecyclerViewを追加する
水平方向にスクロール可能なRecyclerViewでカルーセルは大体実現できます。
val carousel = findViewById<RecyclerView>(R.id.carousel) carousel.apply { // RecyclerViewを水平方向にスクロールできるように設定 layoutManager = LinearLayoutManager(this@MainActivity, LinearLayoutManager.HORIZONTAL, false) // サンプルAdapter adapter = MyAdapter((0..30).map { it.toString() }.toTypedArray()) // カルーセルの両端が中央に表示されるようにRecyclerViewの両端にPaddingを入れる。 val itemWidthDp = 96 // 各項目の幅。サンプル値。 val itemWidthPixels = (itemWidthDp * resources.displayMetrics.density + 0.5).toInt() val paddingH = (resources.displayMetrics.widthPixels - itemWidthPixels) / 2 setPadding(paddingH, 0, paddingH, 0) clipToPadding = false setHasFixedSize(true) }ポイントは2つです。
1. 左右両端にPaddingを入れつつclipToPaddingをfalseにすることで、左右両端の項目を中央に表示させる
2. setHasFixedSize(true)を設定するWeb検索をすると、左右両端の項目にItemDecoratorを使って余白を入れる方法をしばしば見かけますが、それだと項目の削除がある場合には上手く対処できません。末尾項目を削除したとき、中間状態として末尾のItemDecoratorが消えます。その状態で表示位置の調整が行われることで、表示位置が不正になる問題が起きます。なお、画面の表示中にカルーセルの項目を変化させないなら、ItemDecoratorの方法も使えます。
setHasFixedSize(true)を設定するのも、カルーセルの項目数が変化したときに表示位置が不正になる問題を回避するのためです1。
Step 3. カルーセルの項目が丁度良い位置に止まるようにする
GravitySnapHelperを使います。RecyclerViewに付属しているLinearSnapHelperは、中央に表示されている位置(この記事では以下、Snap位置と呼びます)の変化を検知するAPIがないので不便だからです。
val snapHelper = GravitySnapHelper(Gravity.CENTER, true) snapHelper.attachToRecyclerView(carousel)これでカルーセルの項目が丁度良い位置に止まるようになります。
Step 4. Snap位置の変化を検知する
GraivitySnapHelperのAPIに位置変化リスナーがあるので、それを使います。カルーセルの位置をTextViewに表示するサンプルコードを示します。
val textView = findViewById<TextView>(R.id.textView) textView.text = "0" // 最初はリスナー呼ばれないので、初期表示は自分で設定する必要がある snapHelper.setSnapListener { textView.text = it.toString() // it = Snap位置 }番外. プログラムからSnap位置を設定する
GraivitySnapHelper#smoothScrollToPositionを使います。ここまでの手順だけだと、各項目をタップしたときにその項目が中央に表示されません。項目のクリックイベントを拾う + smoothScrollToPositionを呼ぶことで、タップした項目を中央に表示できます。
snapHelper.smoothScrollToPosition(position)smoothでない方のSnap位置設定のAPIもありますが、期待どおりに動作しない場面がしばしばあるので、smoothの方を使うのが無難だと思います(動作しない理由は不明)。
サンプルコード
https://github.com/KamikazeZirou/CenterSnapCarouselSample
カルーセルを作る場合、項目のサイズに応じてRecyclerViewの幅が変化することはないと思うので、基本的に設定できると思います。描画パフォーマンスも上がります。 ↩






































