〈kotlin〉 BottomSheet

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

Material DesignのBottomSheetをサンプルコードをもとに作成してみた。
Material Design
BottomSheetはこういう下から出てくるビューのこと。

Material Design Bottom Sheetsのページより引用

標準的なBottomSheet

<androidx.coordinatorlayout.widget.CoordinatorLayout  
     ...>
    <Button
        android:id="@+id/buttonBottomSheetPersistent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Open Persistent Bottom Sheet" />

    <LinearLayout
        android:id="@+id/persistent_bottom_sheet"
        ...
        //レイアウトをBottomSheetとして定義する
        app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"
        app:behavior_hideable="true" //完全に隠れるようにするかどうか
        app:behavior_peekHeight="30dp" // 表示される高さ>

        //以下にBottomSheetに表示するビューを配置
        <TextView
            android:id="@+id/content1"
            ... />

        <TextView
            android:id="@+id/content2"
            ... />

        <TextView
            android:id="@+id/content3"
            .../>
    </LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

設定にapp:layout_behaviorを追加してBottomSheetとして定義する。
その他追加したいBottomSheetの設定があれば、
app:layout_behaviorを追加したビューに設定するか、
Activityで呼び出して(以下のコードの*部分)設定する。

class MainActivity : AppCompatActivity() {

    private lateinit var binding : ActivityPersistentBottomSheetBinding

    private lateinit var bottomSheetBehavior:BottomSheetBehavior<LinearLayout>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityPersistentBottomSheetBinding.inflate(layoutInflater)
        setContentView(binding.root)

        //*
        //BottomSheetを取得して設定を追加できるようにする
        bottomSheetBehavior = BottomSheetBehavior.from(binding.persistentBottomSheet)

        //状態の変化やスライド操作によるコールバックを登録できる
        bottomSheetBehavior.addBottomSheetCallback(object :BottomSheetBehavior.BottomSheetCallback() {
            override fun onSlide(bottomSheet: View, slideOffset: Float) {
            }

            override fun onStateChanged(bottomSheet: View, newState: Int) {
                binding.buttonBottomSheetPersistent.text = when (newState) {
                    //BottomSheetが閉じているか開いているかを表すStateをもとにボタンの文字を変える
                    BottomSheetBehavior.STATE_EXPANDED -> "Close Persistent Bottom Sheet"
                    BottomSheetBehavior.STATE_COLLAPSED -> "Open Persistent Bottom Sheet"
                    else -> "Persistent Bottom Sheet"
                }
            }
        })


        //BottomSheetを閉じたり開いたりするボタン
        binding.buttonBottomSheetPersistent.setOnClickListener {
            val state =
                if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED)
                    BottomSheetBehavior.STATE_COLLAPSED
                else
                    BottomSheetBehavior.STATE_EXPANDED
            bottomSheetBehavior.state = state
        }
    }
}

BottomSheetとして表示するFragmentを別で用意する。
設定はstyle.xml内に記述する。

<style name="ModalBottomSheet" parent="Widget.MaterialComponents.BottomSheet.Modal">
  <!-- Apply attributes here -->
</style>

<style name="ModalBottomSheetDialog" parent="ThemeOverlay.MaterialComponents.BottomSheetDialog">
  <item name="bottomSheetStyle">@style/ModalBottomSheet</item>
</style>

<style name="AppTheme" parent="Theme.MaterialComponents.*">
  <item name="bottomSheetDialogTheme">@style/ModalBottomSheetDialog</item>
</style>
<androidx.constraintlayout.widget.ConstraintLayout
    ...
    android:id="@+id/modal_bottom_sheet"/>

    <TextView
        android:id="@+id/layout_edit"
       .../>

    <TextView
        android:id="@+id/layout_delete"
        .../>

    <TextView
        android:id="@+id/layout_add"
        .../>
</androidx.constraintlayout.widget.ConstraintLayout>
class BottomSheetFragment : BottomSheetDialogFragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_bottom_sheet, container, false)
    }

    companion object{
        const val TAG = "BottomSheet"
    }
}

ActivityでBottomSheetを追加する。

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        binding.bottomSheetButton.setOnClickListener {
            val bottomSheetFragment = BottomSheetFragment()
            bottomSheetFragment.show(supportFragmentManager, BottomSheetFragment.TAG)

            /*
            コード上でbehaviorの属性を追加する場合は以下のように取得する
            val modalBottomSheetBehavior = (bottomSheetFragment.dialog as BottomSheetDialog).behavior
            */
        }
    }
}

参考
Material Design
Bottom Sheet Android Tutorial - Working with BottomSheet

読書記録:[試して理解]Linuxのしくみ~実験と図解で学ぶOSとハードウェアの基礎知識

こちらを読んだ記録。
gihyo.jp

1章 コンピュータシステムの概要

カーネル
カーネルモードで動作する、OSの核となる処理をまとめたプログラム
CPUやメモリなどのリソースを管理

2章 ユーザモードで実現する機能

プロセス(CPU:ユーザモード)…カーネルに対して処理を依頼するシステムコールを発行
デバイスドライバ(カーネルモード)
→ デバイス(カーネルモード)

システムコールはOSの提供するラッパー関数を呼び出すだけで良い。
Linuxではシステムコールのラッパー関数を含む、標準Cライブラリ(通常はglibc)が提供されている。

3章 プロセス管理

・プロセス生成の関数
fork()関数:同じプログラムの処理を複数のプロセスに分けて処理する(親プロセスのメモリを子プロセス用にコピーして親子で処理を分岐する)。
execve()関数:全く別のプロセスを生成する(あるプロセスを別のプロセスで置き換える)

・終了処理
exit()を呼び出してプロセスに割り当てていたメモリを全て回収する。

4章 プロセススケジューラ

プロセススケジューラ:複数のプロセスを同時に動作させる機能。
1つのCPU上で同時に処理するプロセスは1つだけ。
複数プロセスが実行可能な場合、個々のプロセスを適当な長さの時間ごとにCPU上で順番に処理する。

コンテキストスイッチ:論理CPU上でプロセスが切り替わること

・プロセスの状態
主に 実行状態/実行待ち状態/スリープ状態/ゾンビ状態
ストレージデバイスのアクセス待ち状態が長期間起こっている場合は何かしら問題あり。

スループット:単位時間あたりの総仕事量。高いほど良い。
レイテンシ:それぞれの処理の開始から終了までの経過時間。短いほど良い。

・論理CPUが常に動いている(アイドル状態がない)かつ実行待ちのプロセスがない場合にスループットもレイテンシも最大になる。
→実際は状態変化し続けている。
 アイドル状態(スループット低) ⇆ プロセス実行中(待ちなし) ⇆ プロセス実行中(待ちあり、レイテンシ長)

・論理CPUが複数ある場合はロードバランサかグローバルスケジューラにより、プロセスは公平に分配される。プロセスに実行優先度をつけると、通常より多くにCPU時間が与えられる。

5章 メモリ管理

カーネルのメモリ管理システムによって全メモリが管理されている。
Out of Memory:空きメモリが少なくなるとカーネル内の解放可能なメモリ領域が解放されるが、その後もメモリ使用量が増え続けメモリ不足で何もできなくなった状態

仮想記憶

<基本的な仕組み>
・プロセスからメモリに直接アクセスせず、仮想アドレスを用いて間接的にアクセスする。ページテーブルを使用して仮想アドレスから物理アドレスに変換、ページテーブルエントリ(1つのページに対応するデータ)に仮想アドレスと物理アドレスの対応情報が入っている。
・物理メモリ上では断片化している領域を仮想アドレス空間上で1つの領域として使うことができる。
・プロセスごとに仮想アドレス空間が作られるため、他のプロセスのメモリにはアクセスできない。

<これを応用した仕組み>

ファイルマップ ファイルの領域を仮想アドレス空間上にメモリマップする
デマンドページング 仮想アドレスに対応する物理アドレスは該当ページに最初にアクセスした時に割り当てる
コピーオンライト はじめは親プロセスと子プロセスで仮想アドレスはそれぞれ持つがメモリは共有で持つ、書き込みが起こった際にアクセスされたページを別のメモリにコピーして書き込む
スワップ 物理メモリがOOMの場合、既存の使用中の物理メモリの一部をストレージデバイスに退避して空きメモリを作り出す
階層型ページテーブル ページテーブルを階層構造で持つ
ヒュージページ 大きいサイズのページを使用し、ページテーブルに必要なメモリ量を減らす

6章 記憶階層

・記憶装置の階層構造
レジスタキャッシュメモリ→メモリ→ストレージデバイス
メモリからデータを読み出し、レジスタで計算(処理速い)、結果をメモリに書き戻す

キャッシュメモリ

・メモリからまずキャッシュメモリに書き出すことで高速化される(物理メモリへのアクセスの高速化)。
・データを書き換えた場合は、ダーティという印が付けられ、所定のタイミングでバックグラウンド処理としてメモリに書き戻される。
・階層型キャッシュメモリ:「L1」「L2」などの名前がついており、どのレベルのキャッシュを持つかはCPUに依存する。L1キャッシュが最もレジスタに近く、容量が少なく高速。
キャッシュメモリによる高速化は、プログラムが参照の局所性(ある時点でアクセスされたデータは再度アクセスされる可能性が高い、それに近い場所のデータにアクセスする可能性が高い)を持つため効果的に働く。


Translation Lookaside Buffer(TLB):ページテーブルを参照して仮想アドレスを物理アドレスに変換することを高速化する領域。

ページキャッシュ:ストレージデバイス上のファイルデータをメモリにキャッシュして、ストレージデバイスへのアクセス速度の遅さを埋める。
バッファキャッシュファイルシステムを使わずに、デバイスファイルを用いてストレージデバイスに直接アクセスする際に使う領域。
→この2つまとめて、ストレージ内のデータをメモリ上に置いておく仕組み

ハイパースレッド
CPUコア中の一部の資源を複数用意することでハイパースレッドとして分割して、メモリアクセスのレイテンシが長いためにCPU上で発生しているデータ転送待ち時間を有効活用する機能。
ただしプロセスの挙動によってはスループットが劣化することもあり、20-30%向上すれば良い方。

7章 ファイルシステム

ストレージデバイス内のデータにはファイルシステムを介してアクセスする。
Linuxは、etx4 / XFS / Btrfs など複数のファイルシステムを扱える。

クォータ:用途ごとに使用できるファイルシステムの容量を制限する機能。

・強制電源断が起こるとファイルシステムの不整合が起こる恐れがあるため、以下の方式により不整合を防ぐ。
ジャーナリング:ジャーナル領域に処理の一覧を書き出しておく。
コピーオンライト:更新するデータを別の場所に全て書き込んでから前のデータを破棄する。
fsckコマンドで整合性のある状態への復帰もできるが、時間がかかる上に失敗することもある、望んだ状態に復元されずデータが削除されることがあるため推奨できない。

・デバイスファイル
キャラクタデバイス(端末、キーボード、マウスなど)
ブロックデバイス(HDD, SSDなどのストレージデバイス)

・様々なファイルシステム
メモリベース(tmpfs):ストレージデバイスの代わりにメモリ上にファイルを作成する
ネットワークファイルシステムリモートホスト上のファイルにアクセスする
仮想ファイルシステム(procfs, sysfs, cgroupfs):カーネル内の情報を得る、カーネルの挙動を変更する

・Btrfsはetx4 / XFS より豊富な機能を持つ
マルチボリューム / スナップショット / RAID(データを複数のHDDに分散して保存する) / データ破損検知・修復

8章 ストレージデバイス

・HDD:データを磁気情報で表現して、プラッタという磁気ディスクに記憶するストレージデバイス。セクタという単位で読み書きする。
アクセス要求 → スイングアームの移動、プラッタの回転(ここが時間かかる) → データ転送
データの配置やアクセスするサイズを工夫して、機械的動作の部分が短くなるようにする。
SSD機械的動作がなくデータ検索をすることができ、HDDより高速。

・ブロックデバイス
ブロックデバイス(HDDやSSDなどの、ランダムアクセス可能かつ一定量の大きさ(セクタ)ごとにアクセス可能なデバイス)は共通の処理が多いため、カーネル内のブロックデバイス層が担当する。

I/Oスケジューラ ブロックデバイスのアクセス要求を一定期間貯めておき、マージ・ソートしてからI/O要求することで性能を向上させる。ただし、SSDの場合データ検索に時間がかからないため、I/O要求を貯める処理時間のせいで逆に性能劣化することもある。
先読み データのアクセスに空間的局所性があることを利用して、アクセスされたストレージデバイス内の領域に続く領域を事前に読み込む。

〈kotlin〉通知(NotificationCompat)

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

NotificationCompat APIを使用して通知を作成する方法をまとめる。

通知チャネルの作成

Android 8.0(APIレベル 26)以降、通知はすべてチャネルに割り当てる必要がある。
端末側ではチャネルごとに通知の設定をするため、種類ごとに分けてチャネルを割り当てる。

//古いバージョンではチャネルの設定がなく落ちてしまうので、バージョンによる条件分岐を入れる
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val name = getString(R.string.channel_name) //表示するチャネル名
        val descriptionText = getString(R.string.channel_description) //チャネルの説明文
        val importance = NotificationManager.IMPORTANCE_DEFAULT //通知の重要度

        //一意のチャネルID、名前、重要度を指定したNotificationChannelオブジェクト
        val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
            description = descriptionText
        }
        
        val notificationManager: NotificationManager =
            getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        //チャネルの新規作成
        notificationManager.createNotificationChannel(channel)
    }

通知を行う前にチャネルを作成する必要があるため、アプリ起動時にこのコードを実行する。
(既存のチャネルは再作成されることはないため、何度呼び出されても大丈夫。)

通知の作成、送信

NotificationCompat.Builderに割り当てたい通知チャネルのIDを渡し、通知の内容を設定する。
NotificationManagerCompat.notify()を呼び出して、
通知ごとに一意のIDと作成したNotificationCompat.Builder.build()の結果を渡す。

var builder = NotificationCompat.Builder(this, CHANNEL_ID) //thisはcontext
        .setSmallIcon(R.drawable.notification_icon) 
        .setContentTitle(textTitle)
        .setContentText(textContent) //通常は1行に収まるように切り捨てて表示される
        .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Android 7.1以下での通知の優先度を指定
        .setStyle(NotificationCompat.BigTextStyle() //収まり切らない通知は展開して表示できるようにする
                .bigText("通知の全文")) 

with(NotificationManagerCompat.from(this)) {
        notify(notificationId, builder.build())
}

タップアクションを設定する

通常、通知はタップすると対応するアプリのアクティビティを開くように設定する。
アクティビティの開き方は2種類。
どちらの場合もPendingIntent オブジェクトでインテントを指定し、それをsetContentIntent()に渡し、
さらに setAutoCancel()をtrueにして、タップ後に自動で通知を消すよう設定する。

1.バックスタックを作成する
通知からアクティビティを開くと、バックスタックも作成されて、
戻るボタンを押したときに上位の階層に移動する(ホーム画面など)。
アクティビティの階層を定義する
マニフェストファイルにandroid:parentActivityName 属性を追加する。

<activity
        android:name=".MainActivity"
        android:label="@string/app_name" >
        ...
    </activity>
    
    <activity
        android:name=".ResultActivity"
        android:parentActivityName=".MainActivity" />
        ...
    </activity>

バックスタックを含むアクティビティを起動するPendinigIntent作成
TaskStackBuilderを使用してPendingIntentをセットアップし、新しいバックスタックが作成されるようにする。
TaskStackBuilderのインスタンスからaddNextIntentWithParentStack()を呼び出して、
起動するアクティビティの Intentを渡す。

val resultIntent = Intent(this, ResultActivity::class.java)
val resultPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run {
    addNextIntentWithParentStack(resultIntent)
    getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
    //引数はリスクエストコードとIntentを制御するフラグ
}

val builder = NotificationCompat.Builder(this, CHANNEL_ID).apply {
    setContentIntent(resultPendingIntent)
    ...
}


2.バックスタックはなく、単独で開始する
通知では表示しにくい情報をアクティビティで提供するために、
通知からしか表示されないアクティビティを開く。
アクティビティの属性を定義する
マニフェストファイルに属性を追加し、他のアクティビティ群から開かれないよう設定する。

<activity
        android:name=".ResultActivity"
        android:launchMode="singleTask"
        android:taskAffinity="" // FLAG_ACTIVITY_NEW_TASK フラグと組み合わせて、アプリの他のタスクに含まれないようにする
        android:excludeFromRecents="true" //履歴からこのアクティビティのタスクを除外する>
</activity>

PendingIntent作成

val notifyIntent = Intent(this, ResultActivity::class.java).apply {
    flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val notifyPendingIntent = PendingIntent.getActivity(
        this, 0, notifyIntent, PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(this, CHANNEL_ID).apply {
        setContentIntent(notifyPendingIntent)
        ...
}


基本的な通知の作成の流れはここまで。
他にも画像や会話を表示する展開可能な通知や、
アクションボタンやダイレクト返信を追加、進行状況バーなどカスタマイズできる。
また、Android13から新しく通知実行時の権限が追加されているようなので、
これも見ていく必要がありそう。
通知に関する実行時の権限  |  Android デベロッパー  |  Android Developers

参考
Create a Notification  |  Android Developers
Start an Activity from a Notification  |  Android Developers

Jetpack Composeを使ってみたい④ ViewModel, LiveData

Jetpack Composeを使ってみたい!と思ってCodelabを元に勉強してみた記録。
Jetpack Compose for Android Developers

Composeの状態管理にViewModel, LiveDataを使用する方法をまとめる。
参考は上記コードラボと公式ドキュメント。
Compose とその他のライブラリ  |  Jetpack Compose  |  Android Developers

依存関係

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.compose.runtime:runtime-livedata:$compose_runtime_version"

ViewModel,LiveDataをComposeで使用する

class SampleViewModel() : ViewModel(){
    private var _sampleData = MutableLiveData(0)
    val sampleData : LiveData<Int> = _sampleData
    ...
}
@Composable
fun SampleScreen(
    viewModel: SampleViewModel = viewModel()
){
    val data by viewModel.sampleData.observeAsState()

    data?.let {
        Text(text = "$data")
    }
}

ViewModel
viewModel()関数を呼び出してView Modelにアクセスする。
viewModel()は、既存のViewModelを返すか、指定されたスコープで新しく作成する。
一度作成されたViewModelインスタンスは、スコープが存続している限り保持される。
コンポーザブルがアクティビティで使用されている場合、viewModel()は、
アクティビティが終了するかプロセスが強制終了されるまで同じインスタンスを返す。

LiveData
observeAsState()を使用して、LiveDataをStateとしてモニタリングする。
これによりLiveDataが更新されるたびに、その値を使用したコンポーザブルが再コンポーズされる。
(State型にしないと状態として認識されず、自動的な再コンポーズがされない。)
LiveDataが返す値はnullになる可能性があるため、使用時にnullチェックをする。

前の記事
mtnmr.hatenablog.com

次の記事
mtnmr.hatenablog.com

〈kotlin〉MenuHost, MenuProviderでアプリバーにメニュー表示

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

ホストアクティビティでアプリバーを持ち、
フラグメントでアプリバーにメニューを表示させたい。
この時に使うsetHasOptionMenu(true)が非推奨になっていたので他の方法を調べた。

これまでの方法

setHasOptionMenu(true)を記述することで、
フラグメントがメニュー関連のコールバックを受け取ることをシステムに伝える。

class ExampleFragment : Fragment() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setHasOptionsMenu(true)
    }

    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
        inflater.inflate(R.menu.sample_menu, menu)
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
            ...
        }
    }
}

アクティビティを取得し、MenuProviderインスタンスを追加することで、
メニュー項目をアプリバーに追加できる。

class ExampleFragment : Fragment(R.layout.fragment_example) {

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

    val menuHost: MenuHost = requireActivity()

    menuHost.addMenuProvider(object : MenuProvider {
      override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
        menuInflater.inflate(R.menu.example_menu, menu)
      }

      override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
        ...
        return true
      }
    }, 
    viewLifecycleOwner, 
    Lifecycle.State.RESUMED)
}

addMenuProviderの引数は

  • provider: MenuProvider(メニューリソースと選択時の処理を指定する)
  • owner: LifecycleOwner
  • state: Lifecycle.State

LifecycleOwnerがLifecycle.Stateに達したらMenuProviderを追加する。
lifecycleを追加しておくことで、lifecycleの状態に応じてメニュー項目が自動で制御され、
指定したlifecycleの破棄されるときにMenuProviderも削除される。

参考
android - 'setHasOptionsMenu(Boolean): Unit' is deprecated. Deprecated in Java - Stack Overflow
Activity  |  Android デベロッパー  |  Android Developers
MenuHostHelper  |  Android Developers

Jetpack Composeを使ってみたい③ State

Jetpack Composeを使ってみたい!と思ってCodelabを元に勉強してみた記録。
Jetpack Compose  |  Android デベロッパー  |  Android Developers

Jetpack Composeの基本コードラボのコードを使いながらComposeでの状態の管理についてまとめる。
Jetpack Compose の基本  |  Android Developers

※状態とは時間とともに変化する可能性のある値のこと。

状態の保持

サンプルコードは上記コードラボより。
ボタンをクリックすることでアイテムを展開したり戻したりできるようにし、
展開しているときはボタンのテキストを"Show less"、閉じているときは"Show more"と表示したい。
これを実装するために各アイテムが展開しているかどうかという状態を保持するboolean値を定義する。

//良くない例
@Composable
private fun Greeting(name: String) {
    var expanded = false

    val extraPadding = if (expanded.value) 48.dp else 0.dp

    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
              Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            OutlinedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

onClickにexpandedの値を変更する処理を記述しており、
expandedが変更されたら再コンポーズされて表示が変わることを狙っている。
しかし、この定義の仕方では動作しない。

問題1:Composeが状態の変更に応じて自動で再コンポーズするためには状態をState型で保持する必要があり、このままでは状態の変更が認識されない。
mutableStateOf関数を使用して状態を保持する

問題2:再コンポーズによって変数がfalseにリセットされてしまう。
rememberを使用して状態を記憶する

修正したコードがこちら。

@Composable
private fun Greeting(name: String) {

    val expanded = remember { mutableStateOf(false) }

    val extraPadding = if (expanded.value) 48.dp else 0.dp
    //State.valueで状態を取得する

    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        //省略
    }
}

MutableStateオブジェクトの宣言にはbyデリゲート構文を使用することもできる。
この場合、値を取得する時の.valueは不要になる。

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

var value by remember { mutableStateOf(default) }

rememberでの状態の記憶はアクティビティ全体の再起動(画面の回転など)には対応しておらず、
これに対応するためには代わりにrememberSavableを使用する。

 val expanded = rememberSavable { mutableStateOf(false) }

状態ホイスティング

状態をコンポーザブルの呼び出し元に移動して、
コンポーザブルをステートレスにすることを状態ホイスティングという。

サンプルコードは公式ドキュメントより。
状態と Jetpack Compose  |  Android Developers

//状態ホイスティングなし
@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       //コンポーザブル自身で状態を保持
       var name by remember { mutableStateOf("") }

       if (name.isNotEmpty()) {
           Text(
               text = "Hello, $name!",
               modifier = Modifier.padding(bottom = 8.dp),
               style = MaterialTheme.typography.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           //ここで状態の更新
           label = { Text("Name") }
       )
   }
}

上記のコードよりnameというStateをHelloContent関数の呼び出し元に移動する。
呼び出し元にnameをStateとして定義し直し、
値と値を変更するイベントの2つのパラメータに分けてコンポーザブルに渡す。

//状態ホイスティング
@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

これによって、状態が複数の関数で読み取ったり変更される場合、
状態を重複して持つことなく共有可能になり、
状態を持たないことでコンポーザブルの再利用がしやすくテストも容易になる。


前の記事
mtnmr.hatenablog.com
次の記事
mtnmr.hatenablog.com

Jetpack Composeを使ってみたい② 再コンポーズ

Jetpack Composeを使ってみたい!と思ってCodelabを元に勉強してみた記録。
Jetpack Compose  |  Android デベロッパー  |  Android Developers

JetpackComposeの思想より、再コンポーズについてまとめる。
Compose の思想  |  Jetpack Compose  |  Android Developers

再コンポーズの呼び出しとスキップ

カウント数を表示するTextと、クリックでカウントを増やすButtonを配置したCountScreenと、
カウント数を保持し、CountScreenを呼び出すCounterを以下のように記述した。

@Composable
fun Counter(){
    var count by remember { mutableStateOf(0) }

    CountScreen(
        text = count.toString(),
        onClicked = { count ++ }
    )
}

@Composable
fun CountScreen(count:String, onClicked:() -> Unit){
    Column(
        modifier = Modifier
            .padding(16.dp)
            .fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = count)
        Button(onClick =  onClicked ) {
            Text(text = "count up")
        }
    }
}

ボタンをクリックするとonClickedが実行されてCounter関数内のcountの値が更新される。
countに変更があったため、CountScreenが再度呼び出される。
CountScreen内ではcountの値を使用しているTextコンポーザブルは再コンポーズされ、
Buttonコンポーザブルはパラメータの更新がないため再コンポーズはスキップされる。

副作用と再コンポーズ

副作用とはアプリの他の部分に反映される変更のことで、
例えば

  • 共有オブジェクトのプロパティへの書き込み
  • ViewModel で監視可能なデータの更新
  • 共有設定の更新

再コンポーズはスキップされたり、アニメーションのレンダリングで何度も実行されたり、
並列実行によりバックグラウンドで実行されることがある。
このとき、コンポーズ可能な関数内に直接状態の変更(副作用)が記述されていると、
想定しない状態の変化が起こることがあるため、コンポーズ可能な関数は副作用を持ってはいけない。

副作用のある例

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0
    //リストの要素数を数えたい

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ 
                //関数内に値の変更があり、再コンポーズによって意図しないタイミングで変更が起こる可能性がある
            }
        }
        Text("Count: $items")
        //この場合myList.sizeで要素数を取得すれば、itemsを用意して副作用を持たせる必要はない
    }
}

副作用を持たせる場合はコールバックにする。

//最初のサンプルコードより
@Composable
fun CountScreen(count:String, onClicked:() -> Unit){
    Column(
        modifier = Modifier
            .padding(16.dp)
            .fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = count)
        Button(onClick =  onClicked ) {
            //値の変更はonClickコールバックが持っているため、再コンポーズに依存しない
            Text(text = "count up")
        }
    }
}

任意の順序で実行される

3 つの画面を 1 つのタブレイアウトに描画する次のサンプルコードでは、
コンポーザブル可能な関数StartScreen、MiddleScreen、EndScreenは上から順に実行されるとは限らない。
例えば、StartScreenで値の変更を行い、変更した値をMiddleScreenでも使うというコードを書くと、
StartScreenが先に行われる保証はないので想定した表示にならない可能性がある。
3つのコンポーザブル可能な関数は自己完結している必要がある。

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}


前の記事
mtnmr.hatenablog.com
次の記事
mtnmr.hatenablog.com