به هرمقدار(value) ای که ممکنه باگذشت زمان تو برنامهتون تغییر کنه، بهش میگن State. این تعریف خیلی کلی هست و میتونه هرچیزی رو دَربَربگیره از یک دیتابیس room گرفته تا یه متغیر توی یک کلاس.
چند نمونه از state تو برنامه های اندرویدی:
* یه Snackbar ای که نشون میده: اینترنت برقرار نشد مثلا. مقدار این snackbar با برقرار شدن اینترنت، میتونه تغییر کنه، درنتیجه میگیم: snackbar یه state داره که اگه اینترنت برقرار شد، اون state تغییر میکنه.
* یک پست همراه کامنتهاش. با تعامل کاربر با کامنتها(حذف، ویرایش یا اضافهکردن کامنت)، درنتیجه state کامنت و پست مربوطهاش تغییر پیدا میکنه.
* انیمیشن Ripple ای که کاربر با زدن رو دکمه به نمایش درمیاد، میتونه نمایانگر تغییر state خود انیمیشن باشه.
تو این پست ما ارتباط بین composable و state رو بررسی میکنیم و با API ای که compose دراختیار مون قرار میده میفهمم کجا و چطوری state مون رو ذخیره و استفاده کنیم.
نکته: بعضا من اول متن (یک، دو، سه) گذاشتم؛ این فقط برای راست چین کردن متنه و بار معنایی خاصی نداره.
کامپوز declarative هستش، بنابراین تنها راه برای آپدیت کردنش اینه که یه composable رو با با آرگمان جدید صدا بزنیم. این آرگمان ها نماینده state هستن. هرموقعی که یه state آپدیت بشه، یه recomposition هم صورت میگیره.
@Composable private fun HelloContent() { Column(modifier = Modifier.padding(16.dp)) { Text( text = "Hello!", modifier = Modifier.padding(bottom = 8.dp), style = MaterialTheme.typography.bodyMedium ) OutlinedTextField( value = "", onValueChange = { }, label = { Text("Name") } ) } }
ببینید مقدار OutlinedTextField یه رشتهی خالیه، درنتیجه هرچی کاربر مینویسه، چیزی نوشته نمیشه چون رشته خالیه و تغییری نکرده درنتیجه recomposition ای هم اتفاق نمیافته. پس باید یه راهی پیدا کنیم تا مقدارهایی که کاربر وارد میکنه رو بگیریم و بریزیم توی value اون OutlinedTextField. میبینید که مثل XML نیست که مثلا EditText خودشو آپدیت میکرد، اینجا باید خودمون state رو آپدیت کنیم.
فاکنشنهای composable از remember استفاده میکنن برای ذخیرهکردن یه آبجکت تو حافظه. remember یه مقدار رو محاسبه و ذخیره میکنه درطول composition اولیه. این مقدارهای ذخیرهشده توسط remember، درطول recomposition، برگشت دادهمیشن. تو این مثال، کاربر یهچیزی رو تو TextField مینویسه، همیشه آخرین مقداری که کاربر وارد کرده تو TextField کرده، remember ذخیره میکنه درنتیجه چون اینجا از mutableStateOf استفاده میکنیم برای ذخیره شدن این مقادیر، و کامپوز میدونه این mutableState پایدار و stable هستش درنتیجه با هر تغییر recomposition اتفاق میافته.
این mutableStateOf یه آبجکتی میسازه که state اش قابلمشاهده هست و از نوع
MutableState<T>
هستش.
interface MutableState<T> : State<T> { override var value: T }
خود MutableState<T> یه observable هست که اومده اینترفیس state که با compose runtime ادغام شده رو implement کرده. درنتیجه هرتغییری در مقدارهای mutableStateOf منجر به شروع به recomposition میشه و هر فانکشنی که از اون value استفاده میکنه recompose میشه و درنتیجه اون فانکشن ها آپدیت میشن.
سه روش برای تعریف آبجکت MutableState:
1- val mutableState = remember { mutableStateOf(default) }
2- var value by remember { mutableStateOf(default) }
3- val (value, setValue) = remember { mutableStateOf(default) }
وقتی شما از delegate by استفاده میکنین این پکیج ها import میشه به پروژهتون:
import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue
خب حالا این مقادیر ذخیرهشده، catch شده، بخاطرسپردهشده رو میتونین بعنوان پارامتر یه composable استفاده کنید، حتی میتونین تو statementها بعنوان logic ازش استفاده کنید. برای مثال تو مثال پایین، وقتی نمیخوایین greeting رو وقتی name خالی بود نمایش بدید، از این state ذخیرهشده توی statement if استفاده میکنیم:
@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.bodyMedium ) } OutlinedTextField( value = name, onValueChange = { name = it }, label = { Text("Name") } ) } }
همونطور که گفتم ما برای این از remember استفاده میکنیم که state حفظ بشه تا در هر recomposition ای که اتفاق میافته بتونیم اونو return کنیم تا درنتیجه composable مون آپدیت بشه با state جدید. اما وقتی که configuration change اتفاق میافته، نمیتونه state رو حفظ کنه. درنتیجه اینجاست که باید از rememberSaveable استفاده کنیم. این بصورت خودکار مقدارها رو تو یه bundle ذخیره میکنه درنتیجه هر مقداری که بشه توی bundle ذخیره کرد رو میتونید بهش بدید.
احتیاط: استفاده از آبجکت های تغییرپذیر(mutable) مثل ArrayList<T> or mutableListOf() بعنوان state باعث میشه که کاربر داده های نادرست و قدیمی رو ببینه. چون کلا compose آبجکتهای mutable مثل ArrayList یا data class های mutable رو نمیبینه درنتیجه با تغییر مقادیرشون recomposition ای هم اتفاق نمیافته چون کلا اینا stable نیستن. بجای استفاده از آبجکت های non-observable mutable، توصیه میشه از یه نگهدارنده داده مثل این
State<List<T>> یا listOf()
استفاده کنید.
کامپوز شمارو ملزم نمیکنه که حتما باید از MutableState استفاده کنید برای نگهداری state. کامپوز از یهسری نوع های observable دیگه هم پشتیبانی میکنه. این نگهدارندههای داده باید به State<T>t تبدیل یا ادغام بشن تا کامپوز متوجه تغییراتشون بشه. ولی خود کامپوز اومده observable های رایج تو اندروید رو ادغام کرده با state:
این فانکشن میاد مقادیر رو به روشی lifecycle-aware از یه Flow جمع میکنه که این lifecycle-aware بودنش فایدهای زیادی داره. این متد آخرین مقادیر ساطعشده رو بهوسیله state کامپوز برمیگردونه.
برای ادغام data source های lifecycle-aware ی مثل Flow با compose، این dependency رو به پروژهتون اضافه کنید:
dependencies { ... implementation("androidx.compose.runtime:runtime-livedata:1.4.3") }
این فانکشن مشابه collectAsStateWithLifecycle هستش، چون مقادیر Flow رو جمعآوری میکنه و تبدیلاش میکنه به state کامپوز. اما تفاتشون اینهکه collectAsStateWithLifecycle فقط برای پلتفرم اندرویده، از collectAsState میتونید برای پلتفرمهایی مثل دسکتاپ مثلا استفاده کنید. dependency خاصی هم نیاز نداره.
این متد observe کردن LiveData رو شروع میکنه و مقادیر رو بهوسیله State بهمون میده. برای انجام این کار اول این dependency رو به پروژهتون اضافه کنید.
dependencies { ... implementation("androidx.compose.runtime:runtime-livedata:1.4.3") }
این یه اکستنشن فانکشنه که جریانهای reactive(واکنشیِ) Rxjava2 رو به State کامپوز تبدیل میکنه. جریانها میتونن Single، Observable یا مثلا Completable باشن. برای استفاده از این ویژگی باید این dependency رو به پروژهتون اضافه کنید:
dependencies { ... implementation("androidx.compose.runtime:runtime-rxjava2:1.4.3") }
این هم مثل قبلی یه اکستنشن فانکشنه که جریانهای reactive(واکنشیِ) Rxjava2 رو به State کامپوز تبدیل میکن. جریانها میتونن Single، Observable یا مثلا Completable باشن. برای استفاده از این ویژگی باید این dependency رو به پروژهتون اضافه کنید:
dependencies { ... implementation("androidx.compose.runtime:runtime-rxjava3:1.4.3") }
توجه: بغیر از این فانکشنهایی که کامپوز براتون آماده کرده، شما میتونین یه اکستنشن فانکشن برای دیگر observable ها هم بنویسید. اگه اپ شما از یه کلاس observable سفارشی یا کاستوم استفاده میکنه، باید بااستفاده از produceState تبدیلاش کنین به State<T>t کامپوز.
وقتی یه composable از remember استفاده میکنه برای ذخیره آبجکت، ما میگیم این composable یه internal state برای خودش ایجاد کرده و وقتی یه composable دارای state داخلی هستش، بهاصطلاح میگیم این composable، خودش رو دارای state کرده یعنی stateful هستش. اگه به HelloContent نگاه کنید میبینید که State name رو داخلخودش نگهداری میکنه درنتیجه میگیم HelloContent یه internal state داره و stateful هستش. توجه داشتهباشید، composable هایی که internal state دارن، کمتر قابلاستفادهیمجدد هستن و سختتر میشه تستشون کرد.
درمقابلاش، زمانی که یه فانکشن کامپوزبل، state ای رو داخلخودش نگهداری نداره، میگیم این فانکشن statless هست. یه راه ساده برای دستیابی به یک فانکشن stateless، استفاده از state hoisting هستش.
شمایی که میخواین یه composable قابلاستفادهمجدد(reusable) بنویسید، باید به هردویِ اینا توجه کنید. اگه کسی که composable مون رو صدا میکنه، نیازی به stateاش نداشته باشه، اونوقت از composable stateful استفاده کنید. اما اگه اون صداکننده composableمون نیاز به state composable داره از کاپوزِبِل stateless استفاده کنید.
لغت state hoisting: یعنی بلندکردن state و دادنش به یه کسی که اونو نیاز داره مثلا صداکنندهاش(caller). همون لغتشو یادبگیرین.
چ: State hoisting یه پترنِ تا state رو به caller پاس بدیم تا درنتیجهاش به کامپوزبلی stateless دستبیابیم. کُل پیادهسازیاش هم اینهکه بجای اینکه بیایم متغیر برای state مون تعریف کنیم، بیایم بعنوان پارامتر تعریفش کنیم، با این اسمها:
یک: value: T --» نمایانگر مقدار فعلی برای نمایش
دو: onValueChange: (T) -> Unit --» یه رویداد(event) برای درخواست مقدارهایی که تغییر کردن، T همون مقدار جدیده.
با اینحال شما محدود به onValueChange نیستید، اگه event های بیشتری دارین میتونین با lambdaها تعریفشون کنین، مثلا ExpandingCard دوتا event داره onExpand و onCollapse.
اِستیتی که پاس داده شده یا hoiste شده، این ویژگی های مهم رو دارن:
یک: Single source of truth --» با حرکت دادن state بجای کپی کردنش، مطمئن میشیم که یک منبع حقیقی برای state مون داریم. این باعث باگ کمتر میشه.
شاید این اصل رو تو بهتر بنویسم بعدی توضیح بدم.
دو: Encapsulated --» فقط کامپوزبل های stateful میتونن، state سون رو تغییر بدن.
سه: Shareable --» میتونین اِستیت Hoiste شده مثل name رو با چندین composable به اشتراک بذارین
چهار: Interceptable --» صداکنندهی(caller) کامپوزبل stateless میتونند قبل از تغییر state، وقایع یا event ها رو نادیده بگیرن یا تغییرشون بدهند.
پنج: Decoupled --» اگه کامپوزبلتون stateless باشه، میتونین اون state رو هرجایی که دلتون خواست ازش استفاده کنین یا ذخیره کنین مثلا توی viewmodel.
مثال پایین رو ببینید. اومدیم name و onValueChange رو از HelloContent حرکتدادیم یا hoist کردیم به caller اش یعنی HelloScreen:
@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.bodyMedium ) OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") }) } }
photo
به این الگو که توش state میاد پایین و event ها بالا میرن، میگن جریان داده یکطرفه یا unidirectional data flow. با این الگو، شما میتونید کامپوزبل هایی که state رو نمایش میدن رو از بخش های که اونا رو تغییر یا ذخیره میکنن، جدا کنید.
نکات کلیدی: وقتی که state رو hoist میکنید، سه قانون هستش که بهتون کمک میکنه بفهمید که کجا باید state رو قرار بدید:
1- اِستیت باید حداقل به پایینترین والد مشترکی که دارن فرستاده بشه. برای مثال:
Parent |- Sibling A |- Child A1 |- Sibling B
فرض sibling A و sibling B، دارن یه state مشترک رو میخونن. حالا اگه ما بیایم برای هردو دوتا یه state جداگانه بسازیم بنظرتون چطوره؟ من میگم یه راه حل بهتری هم هست، اونم الگوی بالایی هستش. اینطوری که ما state رو hoist میکنیم به والدشون. بهتر نیست؟
2- اِستیت باید hoist بشه به بالاترین سطحی که ممکنه تغییر پیدا کنه.
3- اگه دو state در پاسخ به یک event یا action مشترک تغییر کنند، باید hoist شون کنیم به parent composable مشترک.
گفتیم که rememberSaveable شبیه به remember هستش با این تفاوت که rememberSaveable چون از saved instance state استفاده میکنه درنتیجه تو هر configuration change، توی هر recomposition، یا اگه activity دوباره صدا زده شد، این دادهای که توش قرار دادیم رو حفظ میکنه.
بالا گفتم که هر مقداری که بشه توی bundle ذخیره کرد رو میتونید به rememberSaveable بهش بدید ومشکلی نداره. ولی آبجکتهایی که نمیشه ذخیرهشون کرد چی؟
بستهبندی یا Parcelize
سادهترین راه parcelable کردنشونه اینطور که شما باید annotation @Parcelize رو به آبجکتتون اضافه کنید. اینجوری:
@Parcelize data class City(val name: String, val country: String) : Parcelable @Composable fun CityScreen() { var selectedCity = rememberSaveable { mutableStateOf(City("Madrid", "Spain")) } }
استفاده از mapSaver
اگه به دلایلی @Parcelize براتون مناسب نبود، میتونید از mapSaver استفاده کنید برای تعریف قانونتون بهمنظور تبدیل یه آبجکت به مجموعهای از مقادیر که سیستم میتونه ذخیره کنه.
data class City(val name: String, val country: String) val CitySaver = run { val nameKey = "Name" val countryKey = "Country" mapSaver( save = { mapOf(nameKey to it.name, countryKey to it.country) }, restore = { City(it[nameKey] as String, it[countryKey] as String) } ) } @Composable fun CityScreen() { var selectedCity = rememberSaveable(stateSaver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) } }
استفاده از ListSaver
برای برای هر فیلد نیاز به تعریف key نباشه، میتونین از listSaver استفاده کنید اینجوری:
data class City(val name: String, val country: String) val CitySaver = listSaver<City, Any>( save = { listOf(it.name, it.country) }, restore = { City(it[0] as String, it[1] as String) } ) @Composable fun CityScreen() { var selectedCity = rememberSaveable(stateSaver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) } }
بلندکردن یا hoist کردنِ اِستیتی که بالا مثالاش رو دیدیم ساده بود و توی خود فانکشن کامپوزبل خودمون مدیریتاش کردیم. اما وقتیکه stateها زیادشدن و logic برنامهتون پیچیده شد درنتیجه مدیریتشون براتون سخته، یه راهحل خوب اینهکه بیاید مسئولیت های منطق و state رو بریزید توی کلاسهایی بنام state holders.
لغت state holder: این کلاس برای مدیریت منطق و state کامپوزبل ها به کار میره.
اینجا پیشنهاد شده دوتا صفحه state hoisting in Compose(اینو فردا کانال میذارم) و State holders and UI State رو بخونید.
خب!! remember اغلب اوقات با نینبن استفاده میشه. اینجوری:
var name by remember { mutableStateOf("") }
اینجا فانکشن remember مقادیر mutableStateOf رو توی هر recomposition حفظ میکنه.
بطور کلی فانکشن remember یه lambda رو بعنوان پارامتر میگیره که این لمبدا یهسری عملیات/محاسبات انجام میده. وقتی این فانکشن برای اولین بار اجرا میشه، لمبدا رو صدا میزنه و عملیات مربوطه اش رو انجام میده، وقتی تموم شد اونو ذخیره میکنه.
جدا از ذخیرهکردن state، شما میتونید هر آبجکت یا نتیجهای در composition که انجاماش زمانبر، پُرمحاسبه یا expensive هستش برای initialize کردن یا محاسبه کردن به remember بدید. با انجام این کار، دیگه اون عملیات expensive هِی توی هر recomposition تکرار نمیشه. برای مثال ساختن ShaderBrush یه عمل expensive هستش:
val brush = remember { ShaderBrush( BitmapShader( ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(), Shader.TileMode.REPEAT, Shader.TileMode.REPEAT ) ) }
همونطور که گفتم لمبدا محاسبات رو انجام میده و نتیجه رو remember ذخیره میکنه تا زمانی که composable ای که از remember استفاده میکنه از ui حذف بشه. با اینحال، ممکنه بخوایم این مقدار ذخیره شده دوباره ارزیابی بشه و اون lambda دوباره اجرا بشه. اینجاست که key ها وارد میشن. remember بجز این lambda یه پارامتر دیگه هم میتونه بگیره بنام key یا keys. اگه مقدار key تغییر کنه بین recomposition ها:
1- اول remember نگاه میکنه، میبینیه که key تغییر کرده.
2- بعد remember مقدار ذخیره شده رو نامعتبر(invalid) فرض میکنه
3- درنتیجه وقتی که بعدا recompose صورت بگیره، دیگه اون مقدار رو. حفظ نمیکنه بلکه lambda دوباره محاسبات رو انجام میده و مقدار جدید رو remember ذخیره میکنه.
کد پایین رو نگاه کنید. یه ShaderBrush ساخته میشه برای بکگراند box مون. remember یه instance ازش ذخیره میکنه. remember این avatarRes رو بعنوان key1 پارامتر میگیره که تصویر پس زمینه انتخاب شده است. اگه avatarRes تغییر کنه، brush با image جدید ریکامپوز میشه و دوباره بکگراند به جعبه اعمال میشه.
@Composable private fun BackgroundBanner( @DrawableRes avatarRes: Int, modifier: Modifier = Modifier, res: Resources = LocalContext.current.resources ) { val brush = remember(key1 = avatarRes) { ShaderBrush( BitmapShader( ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(), Shader.TileMode.REPEAT, Shader.TileMode.REPEAT ) ) } Box( modifier = modifier.background(brush) ) { /* ... */ } }
تیکه کد بعدی درس فردامونه.
خب. rememberSaveable یه پوشنده() ای روی remember هست. که داده رو توی یه bundle ذخیره میکنه. این یه پارامتر input میگیره مثل همون key توی remember عمل میکنه. یعنی هروقت input تغییر کنه توی recomposition بعدی lambda هم دوباره اجرا میشه.
var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) { mutableStateOf( TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length)) ) }
خب اینم از این. امیدوارم یادگرفته باشین، چون تو قسمت های بعدی این مفاهیم پایه ای رو نیاز داریم. اگه جایی رو نفهمیدید تو کامنتها بهاشتراک بذارین.
لطفا پستها رو به اشتراک بذارین تا افراد بیشتری بتونن از اینا استفاده کنن و درنتیجهاش الکی سرمایهشون رو سَر خریدن دوره تلف نکنن.
کانال:Android Corner / ایمیل: cornerdroid@gmail.com