ডেভেলপার রিলেশনস ইঞ্জিনিয়ার
Compose-এর স্ন্যাপশট রানটাইম, অটোমেটিক ডিপেন্ডেন্সি ট্র্যাকিং এবং অ্যাটমিক আপডেট UI লেয়ারে বন্ধ করতে হবে না। Jetpack AppState আপনাকে Compose স্টেট আপস্ট্রিমে নিয়ে যেতে দেয়—UI-তে পৌঁছানোর আগে স্ন্যাপশট সিস্টেমের মাধ্যমে স্টেট হোইস্ট ও ডিরাইভ করে আপনার স্ক্রিনে কী পৌঁছাবে তা স্ট্রিমলাইন করে।
এই সেশনে, আপনি AppState-এর দুটি মৌলিক লাইব্রেরি সম্পর্কে জানবেন। আমরা স্টেট হোস্টিংয়ের জন্য StateStore এবং সাধারণ Kotlin কন্ট্রোল ফ্লো সহ স্টেট ডিরাইভ করার জন্য কম্পোজিশন ব্যবহার করার জন্য Transform-এর বিষয়ে বিস্তারিত আলোচনা করব—জটিল ফ্লো কম্বাইন চেইন বাদ দিয়ে এবং আপনার UI অনায়াসে সিঙ্ক করে।
combine-এর মতো জটিল ফ্লো অপারেটর চেনকে রিপ্লেস করে এবং একই সাথে আর্লি রিটার্ন ও নেস্টেড স্টেট ডেরিভেশনকে সাপোর্ট করে।ট্রান্সস্ক্রিপ্ট করা
হ্যালো, Jetpack AppState-এর মাধ্যমে UI-এর বাইরে কম্পোজ স্টেট নিয়ে যাওয়ার বিষয়ে আপনাকে স্বাগত।
আমার নাম বেন, আমি একজন ডেভেলপার রিলেশনস ইঞ্জিনিয়ার এবং আমি Jetpack আর্কিটেকচার লাইব্রেরিতে কাজ করি।
আমি এলিফ, আমি অ্যাপ আর্কিটেকচার লাইব্রেরিতে কাজ করা ইঞ্জিনিয়ারদের একজন।
আজ আমরা এমন এক ভবিষ্যতের কথা বলতে এসেছি যেখানে Compose, UI-এর বাইরে গিয়ে অ্যাপ্লিকেশনের ডেটা ও ডোমেন লেয়ারে কাজ করবে।
আমি নিশ্চিত যে আপনারা সবাই এখন জানেন যে Compose হল Android-এ UI ডেভেলপমেন্টের জন্য সাজেস্ট করা টুলকিট।
ফোন ও ট্যাবলেট থেকে শুরু করে উইজেট, স্মার্টওয়াচ, টিভি ও XR ডিভাইস, Compose সব কিছুতেই কাজ করে।
কিন্তু আপনার অ্যাপ এইসব UI সারফেস ও ব্যাকগ্রাউন্ড কম্পোনেন্ট জুড়ে বড় হতে থাকলে, সেগুলির পিছনে থাকা স্টেট ম্যানেজ করা কঠিন হয়ে পড়ে, বিশেষ করে দুটি সাধারণ অসন্তোষের কারণের ক্ষেত্রে।
প্রথমটি হল জটিল অবস্থা থেকে সিদ্ধান্ত নেওয়া।
একাধিক ডেটা স্ট্রিম, লোকাল UI ইনপুট এবং নেটওয়ার্ক কানেক্টিভিটির মতো অ্যাম্বিয়েন্ট স্টেট একসাথে বুনতে গিয়ে প্রায়শই combine, flatMapLatest এবং distinctUntilChanged-এর মতো অপারেটরদের জটলা তৈরি হয়।
আর দ্বিতীয়টি হল, বিভিন্ন কম্পোনেন্ট জুড়ে স্টেট শেয়ার করা: অ্যাক্টিভিটি, পরিষেবা, অ্যাপ উইজেট ও আরও অনেক কিছু সিঙ্ক করে রাখা এবং প্রয়োজন হলে সেই স্টেট যাতে প্রসেস বন্ধ করার পরেও বজায় থাকে তা নিশ্চিত করা।
এইসব চ্যালেঞ্জের দিকে নজর দেওয়ার সময় আমরা Compose কীভাবে তৈরি করা হয়েছে তা লক্ষ্য করেছি।
অধিকাংশ মানুষ Compose-কে UI টুলকিট হিসেবে মনে করেন, কিন্তু এটি আসলে দুটি আলাদা লেয়ার: উপরে Compose UI এবং নিচে Compose রানটাইম।
Compose UI লেআউট, ড্রয়িং ও ইনপুট ম্যানেজ করলেও, Compose রানটাইম হল স্টেট ম্যানেজমেন্ট ও রিকম্পোজিশনের জন্য একটি সাধারণ উদ্দেশ্যমূলক ইঞ্জিন।
UI-এর উপর এটি নির্ভর করে না।
আর এই উপলব্ধি থেকেই আমরা Jetpack AppState তৈরি করেছি।
এটি হল লাইব্রেরির একটি ফ্যামিলি যা UI-এর বাইরে Compose স্টেটের সর্বাধিক ব্যবহার করার উপর ফোকাস করে।
তবে AppState-এ যাওয়ার আগে, Compose স্টেট বলতে আমরা কী বুঝি সেই বিষয়ে আমাদের ধারণা যেন একই হয়।
আর উত্তর হল androidx.compose.runtime.State।
/**
* A value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value.
*/
@Stable
public interface State<out T> {
public val value: T
}এবং আপনি সম্ভবত যে ইন্টারফেসের সাথে বেশি পরিচিত, MutableState।
/**
* A mutable value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value. When the
* [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
* will be scheduled. If [value] is written to with the same value, no recompositions will be
* scheduled.
*/
@Stable
public interface MutableState<T> : State<T> {
override var value: T
public operator fun component1(): T
public operator fun component2(): (T) -> Unit
}মূলত, এগুলি হল শুধু বেয়ার-বোন কন্টেনার।
কম্পোজ স্ন্যাপশট সিস্টেমের মাধ্যমে এগুলি কাজ করে।
তাই, আমরা যখন Compose স্টেট বলি, তখন আমরা সাধারণত Snapshot স্টেটকে বোঝাই।
তাই, আপনি mutableStateOf কল করলে, আপনি এমন একটি কন্টেনার তৈরি করছেন যা স্ন্যাপশট সিস্টেমের মাধ্যমে রিড ও রাইট রুট করে।
এবং যখনই আপনার কোড কোনও স্ন্যাপশট অবজার্ভারের মধ্যে রান করে, যেমন কম্পোজিশন, আপনি দুটি প্রপার্টি পান যা ফ্লো-এর মতো স্ট্রিম আপনাকে দেয় না।
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}প্রথমটি হল অটোমেটিক ও ডায়নামিক রিড ট্র্যাকিং।
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}তাই, অটোমেটিক মানে হল যে সাবস্ক্রাইব বা সংগ্রহ করার কোনও প্রয়োজন নেই।
শুধু .value পড়া হল সাবস্ক্রিপশন।
সুতরাং, আমরা যদি এখানে Counter দেখি, তাহলে কম্পোজেবলের মধ্যে সরাসরি count.value রিড করা কম্পোজিশনে রিড করার রিপোর্ট করে এবং এটি তার রিডারের তালিকায় Counter রেজিস্টার করে।
আর এখন AddButton-কে দেখুন।
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}
@Composable
fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Add")
}
}এটি শুধুমাত্র ক্লিকের সংখ্যা গণনা করার জন্য লেখা হয়।
তাই, এটি কোনও রিডার নয়।
কোনও ব্যবহারকারী AddButton-এ ক্লিক করে কাউন্ট পরিবর্তন করলে, স্ন্যাপশট সিস্টেম সেই রাইট ট্র্যাক করে, যার ফলে কম্পোজিশন জানতে পারে যে Counter বাতিল করতে হবে, কিন্তু AddButton নয়।
আর ডায়নামিক বলতে বোঝায় যে রানটাইমে রিড ট্র্যাকিং হয়, যার ভিত্তিতে কোড পাথ আসলে রান করে।
@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(true) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}তাই এখানে, আমরা isVisible ফ্ল্যাগের পিছনে কাউন্টার টেক্সট রক্ষা করছি।
isVisible ট্রু হলে, isVisible এবং সংখ্যা দুটিই পড়া হয়।
তাই, Counter-কে দুটিরই পাঠক হিসেবে রেজিস্টার করা হয়েছে।
আর isVisible মিথ্যা হলে, যদি এটি-- if ব্রাঞ্চ রান না করে, কাউন্ট রিড করা হয় না, তাই কাউন্টার শুধুমাত্র isVisible-এর রিডার হিসেবে রেজিস্টার করা হয়, কাউন্ট নয়।
@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(false) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}তাই, AddButton যখন গণনা করার জন্য লেখে, তখন বাতিল করার মতো আর কিছু থাকে না।
আর এখন, দ্বিতীয় প্রপার্টিকে ধারাবাহিকতা ও গ্র্যানুলারিটির মধ্যে থেকে বেছে নিতে হচ্ছে না।
কনসিস্টেন্সি বলতে বোঝায় যে আপনি যখন কোনও স্ন্যাপশটে একাধিক ভ্যালু আপডেট করেন, তখন আপনার কোড রান করার সময় সেই পরিবর্তনগুলি আলাদা থাকে, এমনকি অন্য কোনও থ্রেড একই সময়ে সেগুলিতে লিখলেও।
স্ন্যাপশট শেষ হলে সেইসব আপডেট একবারে দেখা যায়।
গ্র্যানুলারিটি মানে হল যে আলাদা আলাদা প্রপার্টি ডিক্লেয়ার এবং ট্র্যাক করা যেতে পারে, বিশেষ করে যখন তারা একে অপরের সাথে সম্পর্কিত নয়।
তাই, একটি প্রপার্টি আপডেট করলে শুধুমাত্র সেইসব পাঠককে বাতিল করা হয় যারা সেটির উপর নির্ভর করে।
কিছু আসল কোডে এটি কেমন দেখায় তা দেখা যাক।
যেমন, আমরা এমন একটি গেম দেখব যার তিনটি স্টেট আছে: লেভেলের নাম, কতক্ষণ টিকে আছে এবং স্কোর।
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0তাই শুরু করার জন্য, আসুন একটি কন-- একটি ফ্লো ইমপ্লিমেন্টেশন দেখি যা GameUiState ক্লাসে সবকিছু বান্ডিল করে গ্রানুলারিটির খরচে ধারাবাহিকতা বেছে নেয়।
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)এর অর্থ হল, প্রতিবার updateScore রান করার সময়, আপনি সম্পূর্ণ GameUiState কপি করেন যাতে timeSurvived এবং স্কোর, দুটিই একসাথে সিঙ্ক করে আপডেট করা যায়।
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}UI-তে, GameScreen UI-এর স্ট্যাটাস সংগ্রহ করে এবং হেডার ও ScoreBadge-এর সাথে প্রাসঙ্গিক প্রপার্টি পাস করার জন্য এটি থেকে পড়ে।
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Header(levelName = state.levelName)
ScoreBadge(
timeSurvived = state.timeSurvived,
score = state.score
)
}
}যেহেতু GameScreen প্রতিটি স্টেট পড়ে, তাই updateScore-এ করা প্রতিটি কল GameScreen-কে অবৈধ করে দেয়, যদিও হেডারের ডেটা কখনও পরিবর্তন করা হয়নি।
এখন, গ্র্যানুলারিটি পেতে আপনি প্রতিটি প্রপার্টিকে নিজস্ব ফ্লোতে ভাগ করতে পারেন, তবে আপনি ধারাবাহিকতা হারাবেন কারণ অন্য আপডেট ব্লক চলাকালীন অন্য থ্রেড একটি ভ্যালু আপডেট করতে পারে।
এবার, রাজ্য-ভিত্তিক বিকল্প প্রয়োগের কথা বিবেচনা করা যাক।
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}এটিতে, আমরা আবার GameUiState ব্যবহার করি।
তবে, এই ক্ষেত্রে, এটি একটু আলাদা কারণ প্রতিটি প্রপার্টি mutableStateOf-এ ডেলিগেট করে।
এখানে, updateScore তার রাইট withMutableSnapshot-এ র্যাপ করে এবং এটিই আমাদের রাইট জুড়ে ধারাবাহিকতা প্রদান করে।
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}এবং তারপর UI-তে, GameScreen স্টেটকে সরাসরি হেডার এবং ScoreBadge-এ পাস করে।
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state = viewModel.uiState
Column {
Header(state = state)
ScoreBadge(state = state)
}
}এটি কোনও প্রপার্টি পড়ে না, তাই updateScore রান করলে, GameScreen রিডার হয় না।
এক্ষেত্রে, শুধুমাত্র আসল কম্পোনেন্ট, ScoreBadge, বাতিল করা হয়েছে।
এবং সবশেষে, লক্ষ্য করুন যে এর কোনওটিই UI-এর সাথে যুক্ত নয়।
UI হল এমন একটি জায়গা যেখানে এই প্রপার্টিগুলি সত্যিই উজ্জ্বল হয়ে ওঠে।
আর এরই সাথে, আমাদের আলোচনা শোনার জন্য ধন্যবাদ।
ঠিক আছে, কৌতুক একপাশে রেখে, আমি প্রথম লাইব্রেরি, ট্রান্সফর্মের মাধ্যমে আমাদের নিয়ে যাওয়ার জন্য এটি এলিফের হাতে তুলে দেব।
ঠিক আছে।
ধন্যবাদ, বেন।
সুতরাং, বেন যেমন উল্লেখ করেছেন, কম্পোজ স্ন্যাপশট স্টেট আমাদের অটোমেটিক এবং ডাইনামিক রিড ট্র্যাকিং, ধারাবাহিকতা এবং গ্রানুলারিটি অফার করে, যা আমাকে এই প্রশ্নটি করতে বাধ্য করে, যদি আমাদের ডেটা ফ্লোও একটি কম্পোজ স্টেট স্ন্যাপশট-- একটি স্ন্যাপশট স্টেট দ্বারা চালিত হতে পারে?
আর স্টেট যদি আমাদের ডেটা মডেল হয়, তাহলে আমরা কীভাবে আসলে এর সমস্ত গ্রাহকদের জন্য একটি স্টেট গণনা এবং রূপান্তর করব?
এখানেই নতুন Jetpack AppState Transform লাইব্রেরি কাজে আসে।
লাইব্রেরি সম্পর্কে জানার আগে, লাইব্রেরি কীভাবে তৈরি হল সেই সম্পর্কে আমাদের জানতে হবে।
তাই, Compose ইঞ্জিনের সুবিধা নেওয়ার আইডিয়া, যা এখানে রানটাইম ও কম্পাইলার, হেডলেস কম্পোজিশনে স্টেট ভ্যালু তৈরি করার জন্য, নতুন কিছু নয়।
আপনাদের মধ্যে অনেকেই হয়ত আগে থেকেই Molecule লাইব্রেরি ব্যবহার করছেন বা এর সাথে পরিচিত।
আর এখানে হেডলেস বলতে আমরা বোঝাতে চাইছি যে কোনও UI নেই।
তাই Molecule, Compose Runtime ব্যবহারের ক্ষমতা প্রদর্শনের ক্ষেত্রে অগ্রণী ভূমিকা পালন করেছে এবং এটি আমাদের দেখিয়েছে যে একটি কম্পোজেবল ফাংশনকে UI নোড নির্গত করতে হবে না এবং এটি আসলে কম্পোজেবল ফাংশন লিখতে ব্যবহার করা যেতে পারে যা একটি বিশুদ্ধ ডেটা মডেল রিটার্ন করে।
Molecule-এর ক্ষেত্রে, এটি ছিল Kotlin Flow বা StateFlow।
এর অর্থ হল, কম্বাইনের মতো রিঅ্যাক্টিভ অপারেটর চেইন করার পরিবর্তে আপনি সাধারণ Kotlin লিখতে পারবেন।
কিন্তু ফ্লো নিয়ে কাজ করার পরিবর্তে, আপনার ডেটা মডেল যদি State হয়, কারণ আপনার UI আগে থেকেই Compose-এ আছে?
ট্রান্সফর্মের মাধ্যমে, আপনার ডেটা মডেল হল স্টেট।
আসল API-তে একবার চোখ বুলিয়ে নেওয়া যাক।
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>এটিই হল সম্পূর্ণ API।
এটি খুব সহজ, একটি ফাংশন।
এটি একটি সাধারণ পর্যবেক্ষণযোগ্য State রিটার্ন করে, Flow বা StateFlow নয়।
এবং যে এটি ব্যবহার করে সে জানে না বা চিন্তা করে না যে একটি ট্রান্সফর্ম এটি তৈরি করেছে।
এখানে সবচেয়ে আকর্ষণীয় হল এই ব্লক।
এটি কম্পোজেবল।
এর অর্থ হল, আপনি আগে থেকে যা কিছু জানেন তা এর মধ্যে কাজ করে।
মনে রাখবেন, LaunchedEffect, collectAsState, আগে রিটার্ন করা, সাধারণ ifs, Composable-এর ভিতর থেকে আপনি যা কিছু পড়তে পারেন, Transform-এ আপনি তা থেকে ড্রাইভ করতে পারেন।
আমাদের মনে রাখতে হবে যে এই প্রেজেন্টেশনে আজ আমরা যেসব API দেখছি সেগুলি সবই আলফা ভার্সনে আছে এবং এগুলি পরিবর্তন হতে পারে, তবে যাই হোক না কেন, আসুন একটি উদাহরণ দেখে নেওয়া যাক।
তাই, আসুন খুব সহজ, ছোট ফোকাস টাইমার অ্যাপের উদাহরণ নেওয়া যাক।
আপনি একটি টাস্ক বেছে নেন, ২৫ মিনিটের টাইমার সেট করেন, ঘড়ির কাঁটা চলতে থাকে।
প্রতিবার আপনি যখন একটি স্ক্রিন তৈরি করছেন, এমনকি যখন আপনি এইরকম একটি সাধারণ স্ক্রিন তৈরি করছেন, তখন আপনাকে সম্ভবত ব্যাকগ্রাউন্ডে আপনার স্টেট অ্যাসেম্বল করার জন্য একাধিক স্বাধীন ডেটা স্ট্রিম একত্রিত এবং ম্যানেজ করতে হবে।
আমাদের ফোকাস স্ক্রিনের জন্য, ধরা যাক আমাদের দুটি আছে: ফোকাস সেশন নিজেই, যা আমরা কোন পর্যায়ে আছি তা হতে চলেছে, তাই পজ করা হয়েছে কি না, কত সেকেন্ড বাকি আছে এবং এই সব একটি টাইমার ইঞ্জিন থেকে StateFlow হিসাবে আসতে চলেছে যা প্রতি এক সেকেন্ডে টিক টিক করতে চলেছে।
অন্য ডেটা স্ট্রিমটি ব্যবহারকারীর বর্তমান টাস্ক হতে চলেছে, যা রুম ডেটাবেস থেকে ফ্লো হিসেবে আসবে।
তাই, শেষে দুটি ডেটা স্ট্রিম এবং একটি স্ক্রিন স্টেট।
ViewModel-এ এগুলি একসাথে রাখা হবে এবং এর আউটপুট, যেটিকে আমরা সারসংক্ষেপ বলব, সেটি হবে কোডের অংশ যা আমরা এর পরে লিখব।
সাধারণত, আপনি, এর অর্থ হল আপনার ViewModel-এ, আপনাকে চেইনড অপারেটর ব্যবহার করে এই ফ্লো একত্রিত করতে হবে।
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: StateFlow<FocusSummary> =
combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle,
)
}তাই, দুটি জিপকে একসাথে দুটি ফ্লোতে স্ট্রিম হিসেবে একত্রিত করুন, ল্যাম্বডার ভিতরে ব্রাঞ্চিং, তাই যদি অন্যটি ঠিক সেখানে থাকে এবং তারপরে শেষে একটি স্টেট।
Transform-এর মাধ্যমে, আমরা এই সম্পূর্ণ বয়লারপ্লেট কোড বাদ দিতে পারি।
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)এই কলটি পরিচিত বলে মনে হচ্ছে কারণ আমাদের কাছে একটি স্কোপে একটি প্রাথমিক মান রয়েছে যা পাস করা হচ্ছে, তবে চেইন করার জন্য আপস্ট্রিমে কিছুই নেই, ডেরিভেশনটি ব্লক নিজেই।
stateIn-এর মতো নয়, যখন আপনাকে আপস্ট্রিম স্ট্রিম পজ ও আবার চালু করার জন্য WhileSubscribed টাইম আউট কনফিগার করতে হবে, সেই স্কোপের লাইফটাইমের মধ্যে ট্রান্সফর্ম একটানা চলতে থাকে।
আবারও বলছি, আমাদের সোর্স হল ফ্লো এবং ফ্লোকে স্বাগত জানানো হয়।
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}আমরা প্রত্যেকটিকে collectAsState-এর সাথে ব্রিজ করতে চলেছি এবং সেশনটি হল StateFlow, তাই আমাদের প্রাথমিক ভ্যালুর প্রয়োজন নেই, তবে রুম ফ্লো কোল্ড, তাই আমরা এটিকে একটি দিচ্ছি।
সাবস্ক্রিপশন সংক্রান্ত তথ্য এই পর্যন্তই।
পড়ার অভ্যাসই আমাদের সাবস্ক্রাইব করে।
এটি হল অটোমেটিক রিড ট্র্যাকিং যার ব্যাপারে বেন আগে উদাহরণে বলছিলেন, তবে ViewModel-এ।
এমনকি আমরা যখন অলস অবস্থায় থাকি তখন আমরা এখানে এইরকম প্রাথমিক রিটার্ন চালু করতে পারি।
ধরা যাক, আমাদের কাছে কোনও নতুন টাস্ক নেই, আমরা বাকিগুলিও প্রসেস করব না।
এবং আবার, এটি হল ডাইনামিক রিড ট্র্যাকিং যা আবার পেমেন্ট করছে।
আমরা যখন অলস থাকি, তখন রুম ফ্লো একেবারেই সংগ্রহ করা হয় না।
তারপর আমরা সারসংক্ষেপ প্রকাশ করি।
তাই, ব্যবহারকারী টাস্ক পজ করলে বা টাইমার টিক করলে, সবকিছু অটোমে-- অটোমেটিক আপডেট হয়ে যায়।
সুতরাং, আমরা যখন একটি ViewModel-এর উদাহরণের বাইরে পা রাখি তখন জিনিসগুলি আরও একটু জটিল হয়ে যায়।
ধরা যাক, এই একই সেশন আরও দুটি অতিরিক্ত সারফেসে দেখাতে হবে, একটি মিনি-টাইমার বার যা প্রতিটি স্ক্রিনে অ্যাপের স্ক্রিনের নিচে থাকে এবং একটি হোম-স্ক্রিন উইজেট।
এই দুটির জন্যই সেশনের সারসংক্ষেপ, কোন ফেজ, পজ করা হয়েছে কিনা, কী-- টাইমার শেষ হতে কত সময় বাকি আছে, এই সংক্রান্ত একই তথ্য প্রয়োজন হবে।
এর অর্থ হল, প্রতিটি সারফেস যদি নিজস্ব কপি অ্যাসেম্বল করে, তাহলে একই ডেরিভেশন তিনবার এবং তিনটি আলাদা জায়গায় করতে হবে এবং সবকটি একে অপরের সাথে সামঞ্জস্যপূর্ণ হতে হবে।
তাই, এর পরিবর্তে আমাদের যা করতে হবে তা হল অ্যাপ্লিকেশন স্কোপে একবার সারসংক্ষেপ বের করা এবং তারপরে ডাউনস্ট্রিমের সবকিছু এটি ব্যবহার করবে।
তাহলে, আমরা আসলে কীভাবে এই ধরনের কিছু প্রয়োগ করব?
যেখানে আমাদের রূপায়ণে সম্ভবত তিনটি অংশ প্রয়োজন হবে।
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}সুতরাং, অ্যাপ্লিকেশন স্কোপে একটি সেশন ম্যানেজার যেখানে সারসংক্ষেপ পাওয়া যাবে, একটি মিনি-টাইমার ViewModel এবং একটি Glance উইজেট, এই দুটিই এই ফোকাস সেশন ম্যানেজারে প্রাপ্ত সারসংক্ষেপ ব্যবহার করবে।
চলো, প্রতিটি এনক্লোজার প্রথমে ঐতিহ্যবাহী পদ্ধতিতে বানাই আর তারপর ট্রান্সফর্ম ব্যবহার করে বানাই।
তাই, প্রথাগতভাবে, আবার, আমরা অ্যাপ্লিকেশন স্কোপে একবার StateFlow হিসেবে সারসংক্ষেপ বের করে শুরু করব।
class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: StateFlow<FocusSummary> =
combine(
sessionFlow,
taskRepository.tasksFlow
) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}এটি ViewModel উদাহরণে আগে দেখা একই combine ও stateIn কল, তবে এটি এক লেভেল উপরে সরানো হয়েছে।
ট্রান্সফর্মের মাধ্যমে, আমরা আগে দেখা ViewModel থেকে একই ঘোষণা আবার ব্যবহার করতে পারি।
class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
) {
val session by sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow
.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}শুধুমাত্র যে জিনিসটি পরিবর্তন হয়েছে তা হল, এটি এখন অ্যাপ্লিকেশন স্কোপে থাকে।
এরপর, আমাদের কাছে মিনি-টাইমারের ViewModel আছে।
class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: StateFlow<MiniTimerUiState> =
combine(
focusManager.summary,
isExpanded
) { summary, expanded ->
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MiniTimerUiState()
)
fun toggleExpanded() { isExpanded.update { !it } }
}ধরা যাক, আমরা এখানেও নিজেদের একটু চ্যালেঞ্জ করতে চাই।
ধরা যাক, মিনি-টাইমারে, আমাদের কাছে আগে থেকে থাকা দুটি ডেটা স্ট্রিম, StateFlow এবং রুম ফ্লো ছাড়াও, আমরা টাইমার বারটি বড় করা হয়েছে কিনা তাও ট্র্যাক করতে চাই।
আর তার জন্য আমরা MutableStateFlow ব্যবহার করি।
এটি করার জন্য, আমাদের সারসংক্ষেপ ফ্লো এবং isExpanded StateFlow ট্র্যাক করতে অন্য একটি কম্বাইন ব্যবহার করতে হবে।
এবং আমরা দ্বিতীয় স্টেট পাই।
Transform-এর মাধ্যমে, ডেটা স্ট্রিমের মধ্যে সিঙ্ক করার জন্য আমাদের আবার এইসব চেইনড অপারেটর ব্যবহার করতে হবে না।
class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: State<MiniTimerUiState> =
transform(
initialValue = MiniTimerUiState(),
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val summary by focusManager.summary
val expanded by isExpanded.collectAsState()
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}
fun toggleExpanded() { isExpanded.update { !it } }
}আমরা যা করতে পারি, এবং এটি সত্যিই দারুণ, তা হল আমরা নেস্টেড ট্রান্সফর্ম করতে পারি।
সুতরাং, এখানে ViewModel সারসংক্ষেপটি পড়ে, যা অন্য যেকোনও অবস্থার মতো আপস্ট্রিম ট্রান্সফর্মের আউটপুট এবং তারপরে এটি collectAsState-এর সাথে isExpanded সংগ্রহ করে।
তাই, আমরা একই ব্লকে StateFlow-এর মধ্যে আপস্ট্রিম স্টেট ও ডাউনস্ট্রিম ফ্লো মিশিয়ে দিতে পারি।
এবং গ্রানুলারিটি মনে রাখবেন।
তাই, আপনি isExpanded টগল করলে, এটি শুধুমাত্র এই ব্লকটি আবার রান করবে।
অ্যাপ-লেভেল ব্লক এবং উইজেটকে এমনকি লক্ষ্যও করতে হয় না।
সবশেষে, আমাদের কাছে উইজেট আছে।
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Glance হল Compose, তাই আমরা অন্য যেকোনও Composable-এর মতো provideContent-এর মধ্যে collectAsState সহ StateFlow সংগ্রহ করতে পারি।
'ট্রান্সফর্ম' ফিচারের মাধ্যমে এটি আরও সহজ হয়ে গেছে।
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}আমাদের ফোকাস ম্যানেজার আমাদের একটি কম্পোজ স্টেট প্রদান করছে, তাই এখানে আমাদের কনভার্ট করার মতো কিছু নেই।
এবং শেষ পর্যন্ত, মিনি-টাইমার বার এবং হোম-স্ক্রিন উইজেট কখনই একমত হতে পারে না কারণ তারা একই অবস্থা পড়ছে।
শুধু তাই নয়, সেই স্টেট পরিবর্তন করা হলে, সেই স্টেটের সব কনজিউমারের ক্ষেত্রেও তা প্রযোজ্য হবে।
এর অর্থ কী?
তাই, আপনি যদি এখানে উইজেটে পজ করেন, তাহলে এটি ম্যানেজারের মাধ্যমে সেশন স্ট্রিমে যাবে, সারসংক্ষেপের মাধ্যমে ফিরে আসবে এবং এই তিনটি স্ক্রিনই সিঙ্ক করে আপডেট করা হবে।
তাহলে, এখন আমরা Transform কীভাবে ব্যবহার করতে হয় তা একটু দেখেছি, আসুন এবার আরও গভীরে যাওয়া যাক।
আমরা আগে যে Transform API দেখেছি সেটি আসলে একটি হেল্পার ফাংশন যা একটি বেসিক ম্যানেজড Transform তৈরি করতে ব্যবহৃত হয়।
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R> {
val transformScope = TransformScope()
val transform = transformScope
.launchTransform(initialValue, onUpdate)
scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
) {
try { transformScope.run() } finally {
transform.dispose()
}
}
return transform
}বাস্তবে, এটি আসলে একটি TransformScope এবং একটি launchTransform এক্সটেনশন ফাংশন ব্যবহার করে, যা এই TransformScope-এ একটি কম্পোজেবল ট্রান্সফর্মেশন চালু করে এবং একটি ডিসপোজেবল স্টেট রিটার্ন করে।
আপনি এটি উপর থেকে নিচ পর্যন্ত পড়লে, দেখতে পাবেন যে এটি আপনার জন্য সবকিছু করে।
এটি TransformScope তৈরি করে এবং আপনার ট্রান্সফর্মকে এতে লঞ্চ করে, যা আপনি ফিরে পাবেন।
এবং তারপরে এটি আপনার প্রদান করা ডিসপ্যাচারে TransformScope-এ রানও লঞ্চ করে, যাতে স্কোপের লাইফটাইম এবং থ্রেড আপনার হয়।
এবং তারপর স্কোপ শেষ হলে আপনার ট্রান্সফর্মেশন ডিসপোজ করে।
এটি Transform-এর সম্পূর্ণ লাইফ-সাইকেল স্টোরি।
তাহলে, আসুন আমরা এখানে TransformScope ইন্টারফেসটি খুব দ্রুত দেখে নিই।
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
public abstract class TransformScope {
public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}
public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>
public interface DisposableState<T> : State<T> {
public fun dispose()
}আমরা দেখতে পাচ্ছি যে এতে একটি createTransform ফাংশন আছে, যা এই TransformScope-এর সাথে অ্যাটাচ করা ট্রান্সফর্ম তৈরি করতে চলেছে।
এছাড়াও, এতে সাসপেন্ডিং রান ফাংশন আছে, যা TransformScope শুরু করে এবং বর্তমান স্টেট ট্রান্সফর্মেশন আপডেট করার প্রসেস শুরু করে।
এর অর্থ কী?
তাই, রান আসলে Recomposer এবং কম্পোজিশন সেট-আপ করে।
এবং স্কোপের প্রতিটি ট্রান্সফর্ম সেই কম্পোজিশনের মধ্যে থাকে এবং এর নিজস্ব রিকম্পোজ স্কোপ থাকে।
এই কারণেই আমরা স্কোপ শেয়ার করতে চাই এবং আমাদের TransformScope আছে।
যাতে যখনই কোনও স্টেট পরিবর্তন হয়, শুধুমাত্র ট্রান্সফর্ম যা এটি পড়ে তা আবার রান করে।
আমরা আগেই উল্লেখ করেছি যে run হল একটি সাসপেন্ড ফাংশন।
override suspend fun run() {
val context = currentCoroutineContext()
val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
...
val recomposer = Recomposer(context)
val composition = Composition(UnitApplier, recomposer)
try {
withContext(clock) {
if (clock is HeadlessTransformClock) {
launch(start = CoroutineStart.UNDISPATCHED) {
clock.runClock()
}
}
...
}
} finally {
// dispose of composition, cancel the clock, close the recomposer
}
}তাই, আপনি যে coroutine থেকে এটি কল করেন, সেটি বাতিল না করা পর্যন্ত এটি সেই coroutine-এ রান করে।
এখন, আপনি জিজ্ঞাসা করতে বা ভাবতে পারেন, একটি Recomposer-এর একটি ফ্রেম ঘড়ির প্রয়োজন হয় যাতে এটি কখন একটি ফ্রেম তৈরি করতে হবে তা বলতে পারে।
Compose UI-তে, এটি হল ডিসপ্লে, তবে আমরা আগেই উল্লেখ করেছি যে আমরা হেডলেস এনভায়রনমেন্টে আছি, তাই কোনও UI নেই।
তাই, আপনি আমাদের ঘড়ি না দিলে, আমরা নিজেদের ঘড়ি তৈরি করব।
ডিসপ্লে ফ্রেম ক্লক থেকে রিকম্পোজিশন ডিকাপল করার মাধ্যমে, Transform তার সব ট্রান্সফর্মেশন ও স্টেট ডেরিভেশন UI থ্রেডের বাইরে রান করে।
এটির অনুমতি দিতে, আমরা একটি বিশেষ HeadlessTransformClock তৈরি করেছি।
ফ্রেম ক্লক ট্রান্সফর্ম করুন।
যেহেতু কোনও উইজেট, পরিষেবা বা অন্য কোনও ট্রান্সফর্ম ডাউনস্ট্রিম, ট্রান্সফর্ম থেকে কোনও ভ্যালুর জন্য অপেক্ষা করতে পারে, তাই আমাদের ঘড়িটি ইগার।
এর অর্থ হল, ট্রান্সফর্ম যে স্ট্যাটাস ব্যবহার করছে তাতে কোনও পরিবর্তন হলে, সাথে সাথে একটি নতুন ফ্রেম তৈরি হয়।
এটি একটি ছোট লক-ফ্রি স্টেট মেশিন, তাই ফ্রেমের অনুরোধ করা একটি সহজ তুলনা-এবং-সেট অপারেশন।
এবং যখন স্টেটে কোনও পরিবর্তন হয় না, তখন ঘড়ি পার্ক করে।
তাই, কোনও ট্রান্সফর্ম যদি এমন কোনও স্টেট ব্যবহার করে যা পরিবর্তন করা হয়নি, তাহলে আপনার কোনও খরচ হবে না।
Ben-এর কাছে ফিরে যাওয়ার আগে, আমাদের এটিও মনে রাখতে হবে যে কনফিগারেশন পরিবর্তন জুড়ে আপনার ডেটা যাতে সেভ করা থাকে তা নিশ্চিত করতে Transform এখন rememberSaveable-কে সাপোর্ট করে।
এটি নতুন SavedStateContainer API-এর মাধ্যমে করা হয়, যা এখন বিল্ডিং ব্লক হিসেবে কাজ করে এবং এর উপর ভিত্তি করে Compose-এর SaveableStateRegistry, SavedStateHandle এবং SavedStateRegistry তৈরি করা হয়েছে।
আপনি এগুলির যেকোনও একটিকে SavedStateContainer-এ পরিবর্তন করতে পারেন অথবা চাইল্ড কন্টেনার তৈরি করে আপনার ডেটা সেভ করা হয়েছে কিনা তা নিশ্চিত করতে Transform-এ পাস করতে পারেন।
সবশেষে, আরও একবার মনে করিয়ে দিই যে আজ আমরা যেসব API নিয়ে আলোচনা করছি সেগুলি সবই আলফা ভার্সনে আছে, তাই এগুলি পরিবর্তন হতে পারে।
এই বলে, আমি বেনকে আবার মাইক ফিরিয়ে দিচ্ছি।
ঠিক আছে।
ঠিক আছে, ধন্যবাদ, এলিফ।
সুতরাং, এখন আমরা দেখেছি যে Compose স্টেট কতটা শক্তিশালী এবং কীভাবে Transform আমাদের UI-এর বাইরে এটি পেতে দেয়, আপনি এখন পর্যন্ত উদাহরণে একটি জিনিস লক্ষ্য করেছেন যে আমাদের ইনপুটগুলি ছিল ফ্লো যা আমরা collectAsState ব্যবহার করে ব্রিজ করেছি।
কিন্তু এটা কি সবসময় প্রয়োজন?
আমাদের স্টেট হোল্ডারে সরাসরি Compose স্টেট ব্যবহার করা হয় না কেন?
তাই, সেই প্রশ্নের উত্তর দিতে, আমি তিনটি বিস্তৃত বিভাগের মধ্যে রাজ্য সম্পর্কে চিন্তা করাকে সহায়ক বলে মনে করি।
প্রথমটি হল লাইভ স্টেট যা শুধুমাত্র মেমরিতে সেভ করা হয়।
নেটওয়ার্ক বা ব্লুটুথ কানেকশন স্ট্যাটাসের মতো বিষয়গুলি ভাবুন।
আপনি কোনও পারসিস্টেড ভ্যালু ব্যবহার করতে চাইবেন না।
এবং তারপরে, দ্বিতীয়টি হল ট্রানজিয়েন্ট স্টেট যা প্রসেস বন্ধ করার পরেও থেকে যায়, তবে অনির্দিষ্টকালের জন্য স্টোর করার প্রয়োজন হয় না।
UI স্টেট, যেমন কোনও কার্ড বড় করা হয়েছে কিনা এবং সেশন স্টেট, যেমন আপনার নেভিগেশন ব্যাক স্ট্যাক বা ফর্ম ড্রাফ্টের মতো বিষয়গুলি বিবেচনা করুন।
এবং তারপর, তৃতীয়টি হল স্থায়ী অবস্থা, যা অনির্দিষ্টকালের জন্য স্টোর করা হয়।
ব্যবহারকারীর পছন্দ বা ডেটাবেসে থাকা মিডিয়া লাইব্রেরির মতো বিষয়গুলি বিবেচনা করুন।
তাহলে, লাইভ ও ট্রানজিয়েন্ট স্টেট, দুটির জন্যই কীভাবে Compose স্টেট ব্যবহার করবেন তা দিয়ে শুরু করা যাক।
এখনও পর্যন্ত, আমাদের আর্কিটেকচার সংক্রান্ত নির্দেশিকা UI লেয়ারে বা কখনও কখনও ViewModels-এ Compose স্টেট রাখে এবং আমরা আপনাকে অন্য সব জায়গায় Flow ব্যবহার করতে বলেছি।
কিন্তু কেন?
কারণ, Flow-এর সাথে স্টেট প্রপার্টি একত্রিত করার প্রয়োজন হলেই, আপনাকে snapshotFlow ব্যবহার করে এটিকে আবার কনভার্ট করতে হবে।
কিন্তু Transform-এর ক্ষেত্রে, এই কনভার্সন করার প্রয়োজন নেই।
তাহলে, আসুন দেখে নেওয়া যাক এটি দেখতে কেমন বা এর অর্থ কী, ViewModel থেকে শুরু করে।
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}লোকাল স্ক্রিন ইনপুটের জন্য, যেমন এখানে selectedTaskIds, আপনি MutableStateFlow-এর পরিবর্তে প্লেন mutableStateOf প্রপার্টি ঘোষণা করতে পারেন।
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}এবং যেহেতু এটি এমন কিছু যা আপনি প্রসেস বন্ধ করার পরেও রাখতে চান না, তাই এটি ঠিক আছে যে এটি mutableStateOf ব্যবহার করছে না।
সার্চ কোয়েরির মতো যেসব জিনিস আপনি প্রসেস বন্ধ করার পরেও রাখতে চান, সেগুলির পরিবর্তনযোগ্য স্ট্যাটাস আপনি সরাসরি savedStateHandle.saveable-এর মাধ্যমে ব্যাক-আপ নিতে পারেন।
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}এবং তারপর, আগে গেমের উদাহরণে updateScore-এর মতো, যখন কোনও পদ্ধতি একসাথে একাধিক স্টেট ভ্যালু আপডেট করে, তখন withMutableSnapshot-এ রাইট র্যাপ করা হলে তা ধারাবাহিকতা নিশ্চিত করে।
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
fun updateSearchQuery(query: String) {
Snapshot.withMutableSnapshot {
searchQuery = query
selectedTaskIds = emptySet()
}
}
}তারপর, রুম ফ্লোর সাথে searchQuery একত্রিত করতে, ফ্লো সংগ্রহ করার সময় আপনি শুধু ট্রান্সফর্মের মধ্যে সরাসরি এটি পড়ে নিন।
val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}তাহলে, নেটওয়ার্ক কানেক্টিভিটির মতো লাইভ স্ট্যাটাস সম্পর্কে কী বলা যায়?
@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}এটি সাধারণত সিঙ্গলটনে থাকে কারণ এটি একাধিক স্ক্রিন বা এমনকি একাধিক অ্যাপ কম্পোনেন্ট, যেমন পরিষেবার মাধ্যমে পড়া হয়।
তাই, কিন্তু তার মানে এই নয় যে আপনি এটি স্টোর করার জন্য কম্পোজ স্টেট ব্যবহার করতে পারবেন না।
আসলে, এটি প্রায়ই ফ্লো-ভিত্তিক ভার্সনের থেকে সহজ হয়।
অ্যাডাপ্ট করার জন্য আপনার কোনও কলব্যাক ফ্লো বা অন্য কিছুর প্রয়োজন নেই, আপনি শুধু কলব্যাকে সরাসরি ডট ভ্যালু আপডেট করুন।
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}এছাড়াও, কোনও গ্রাহকের এখনও ফ্লো প্রয়োজন হলে, আপনি snapshotFlow ব্যবহার করে আবার ব্রিজ করতে পারবেন।
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { _isOnline.value = true }
override fun onLost(network: Network) { _isOnline.value = false }
}
init {
connectivityManager.registerDefaultNetworkCallback(callback)
}
}তাহলে, ViewModel-এ সেটি কেমন দেখতে লাগে তা দেখে নেওয়া যাক।
আপনি সরাসরি Transform-এ isOnline পড়তে পারবেন এবং ভ্যালু পরিবর্তন হলে এটি আবার কম্পোজ করবে।
val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
if (!networkMonitor.isOnline.value) {
return@transform TaskListState.Offline
}
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}এবং, এই সব কিছু সরাসরি ঘটছে, কোনও অ্যাপ্লিকেশন স্কোপ বা কোরুটিন স্কোপ নেই যা আপনাকে স্ট্যাটাস আপডেট করার জন্য দিতে হবে, এটি, আপনি জানেন, একটি সরাসরি, সিঙ্ক্রোনাস আপডেট।
তাই, Transform-এর মাধ্যমে, আপনার ViewModel, Singleton ম্যানেজার থেকে স্টেট, ViewModel-এ স্টেট এবং রিপোজিটরি থেকে ফ্লো, সবকিছু একটিই Transform ব্লকে একত্রিত করতে পারে।
এবং, এখন ট্রানজিয়েন্ট স্টেটে ফিরে আসা যাক।
আগে আমি দুই ধরনের কথা উল্লেখ করেছি: UI স্টেট ও সেশন স্টেট।
UI স্টেট এবং এমনকি সেশন স্টেট যা একটি অ্যাক্টিভিটির মধ্যে থাকে, তার জন্য ViewModel এবং SavedState দারুণ কাজ করে।
ঠিক এই কারণেই এগুলি ডিজাইন করা হয়েছে।
কিন্তু, যখন সেশন স্টেট বিভিন্ন UI এবং অ্যাপ কম্পোনেন্ট জুড়ে শেয়ার করতে হয়, তখন সমস্যা শুরু হয়।
সেই অবস্থা ViewModel-এর বাইরে চলে গেলে, আমাদের বর্তমান API এবং নির্দেশিকা কিছুটা ফাঁক রেখে যায়।
তাই, প্রথমে SavedState API একটি অ্যাক্টিভিটির বান্ডেলে সেভ করে, কিন্তু এটি অন্যান্য অ্যাপ কম্পোনেন্টে উপলভ্য নয়, যেমন কোনও পরিষেবা।
দ্বিতীয়ত, সিঙ্গলটনে mutableStateOf, যা শুধু মেমরিতে থাকে, তাই প্রসেস বন্ধ হয়ে গেলে এটি আর থাকে না।
আর, Jetpack AppState, StateStore সেই ফাঁক পূরণ করে।
এটি হল Compose State-এর টাইপ-সেফ ম্যাপ যা অ্যাপ কম্পোনেন্ট জুড়ে শেয়ার করা যায় এবং প্রসেস বন্ধ হয়ে গেলেও যাতে ডেটা পাওয়া যায় তার জন্য DataStore-এর মাধ্যমে ব্যাক-আপ নেওয়া হয়।
আপনি নিজেই প্রতিটি সিঙ্গলটনে DataStore ওয়্যার করতে পারেন, তবে এর অর্থ হল প্রতিটি প্রপার্টির জন্য সেভ এবং রিস্টোর বয়লারপ্লেট লেখা।
StateStore DataStore ইন্টিগ্রেশনের মাধ্যমে, আপনি একবার DataStore হুক আপ করেন এবং তারপরে আপনি যে স্টেট বজায় রাখতে চান তার প্রতিটি ছোট অংশ চালু করেন।
তাই, আমাদের ফোকাস টাইমার অ্যাপে এটি তৈরি করা যাক।
আমরা প্রথমে ইন-মেমরি শুরু করতে চলেছি এবং তারপরে DataStore পারসিস্টেন্স যোগ করব।
To set the stage, we're modeling a FocusSession as a sealed interface with idle, running, and paused states.
sealed interface FocusSession {
data object Idle : FocusSession
data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}তারপর, StateStore-এ এই স্টেট স্টোর করার জন্য, আমরা StateStoreKey-এর ধরন, FocusSession এবং ডিফল্ট ভ্যালু, idle হিসেবে উল্লেখ করেছি।
sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)এবং তারপরে, আমরা Hilt-এ একটি সিঙ্গলটন StateStore প্রদান করি যা আমরা বিভিন্ন কম্পোনেন্টে ইনজেক্ট করতে পারি যেখানে আমাদের এটি প্রয়োজন, যেমন Focus Session Manager।
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}সুতরাং, ViewModel যেভাবে SavedStateHandle ইনজেক্ট করে তার মতোই।
ঠিক আছে।
তাহলে, এখন আমাদের কাছে StateStore আছে, কিন্তু আমরা এটি কীভাবে ব্যবহার করব?
ভালো কথা, এটি একটি ছোট API।
এটির মাত্র তিনটি পদ্ধতি আছে, তাই আমরা এক এক করে সেগুলি দেখব।
প্রথমটি হল setState, যা কোনও 'কী'-এর জন্য নতুন ভ্যালু লেখে।
fun startFocus(taskId: String, durationMinutes: Int = 25) {
val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
stateStore.setState(
FocusSessionKey,
FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
)
}
fun endFocus() {
stateStore.setState(FocusSessionKey, FocusSession.Idle)
}সেশন শুরু হলে, আমরা এটিকে 'চলছে' হিসেবে সেট করি এবং সেশন শেষ হলে, আমরা এটিকে 'আইডল' হিসেবে সেট করি।
এইটুকুই।
দ্বিতীয়ত, আমাদের কাছে এখনও updateState আছে, যা আপনাকে বর্তমান ভ্যালুর উপর ভিত্তি করে নতুন ভ্যালু গণনা করতে দেয়, যেমন, চালু ও পজ করার মধ্যে পরিবর্তন করার সময় বাকি থাকা সময় গণনা করা।
fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Running) return@updateState session
FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
}
}
fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Paused) return@updateState session
FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
}
}এবং সবশেষে, getState আছে, যা কোনও 'কী'-এর জন্য শুধু-পড়ার-উপযোগী স্টেট ফিরিয়ে দেয় এবং ডিফল্ট ভ্যালু ওভাররাইড করার বিকল্পও এতে আছে, যদি আপনি কোনও নির্দিষ্ট কল সাইটে সেটি পরিবর্তন করতে চান।
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}এছাড়াও, এই ম্যানেজারে FocusSession স্টেট এক্সপোজ করার অর্থ হল, গ্রাহকদের কখনই StateStore বা এর 'কী' সরাসরি অ্যাক্সেস করতে হবে না।
আপনি ইন-- রাইট ও রিড এনক্যাপসুলেশন বজায় রাখতে পারবেন।
এবং তারপরে, কনজিউমারের পরিবর্তে তারা শুধু Focus Session Manager ইনজেক্ট করে, যা, সম্ভবত তাদের কাছে আগে থেকেই আছে কারণ তাদের সেশন কন্ট্রোল করতে হবে এবং তারপরে তারা স্টেট পড়তে পারবে।
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}তারপর, সেইসব রিডার, সরাসরি স্টেট আবার এক্সপোজ করতে পারেন অথবা ট্রান্সফর্ম ব্যবহার করে নতুন, আপডেট করা স্টেট তৈরি করতে পারেন, যেমনটা আমরা আগে মিনি-টাইমার ViewModel-এর উদাহরণে দেখেছি।
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}তারপর, UI কম্পোজেবল অন্যান্য Compose স্টেটের মতোই সেই স্টেট রিড করতে এবং প্রয়োজন হলে রিকম্পোজ করতে পারে।
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}
@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
val session by viewModel.session
if (session !is FocusSession.Idle) {
TimerReadout(session = session)
}
}কিন্তু এখনও পর্যন্ত, FocusSession শুধু স্মৃতিতেই বেঁচে আছে।
তাহলে, প্রসেস বন্ধ করার হাত থেকে বাঁচতে কী করতে হবে তা দেখা যাক।
তাই, প্রথমে, স্টোর তৈরি করার সময় syncToDataStore লঞ্চ করে আমাদের কিছু ওয়ান-টাইম সেট-আপ করতে হবে।
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
): StateStore = StateStore().apply {
val path = context.dataStoreFile("app_state.json").absolutePath
scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
}
}তারপর, আমরা যে 'কী' রাখতে চাই, সেটি চালু করি।
@Serializable
sealed interface FocusSession {
@Serializable data object Idle : FocusSession
@Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
@Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
PersistToDataStoreআমাদের শুধু নিশ্চিত করতে হবে যে ধরনটি সিরিয়ালাইজেবল এবং কীটি PersistToDataStore ইন্টারফেস প্রয়োগ করে।
আর তারপর, আপনি হয়তো ভাবছেন, ঠিক আছে, এটা তো সহজ মনে হচ্ছে।
তাহলে, 'কী' বজায় রাখার জন্য এটি আসলে কী করে?
তাই, syncToDataStore, Transform-এর লিসেনার API ব্যবহার করে, যা মূলত Transform-এর মতোই, তবে এটি কোনও কিছু রিটার্ন করে না, এটি শুধুমাত্র পর্যবেক্ষণ এবং সাইড এফেক্টের জন্য ব্যবহৃত হয়।
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}তারপর, সেই লিসনারের মধ্যে, এটি স্টোরের প্রতিটি কী পড়ে এবং চেক করে দেখে যে কোনও একটি PersistToDataStore কী কিনা।
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener {
for (key in keys) {
if (key is PersistToDataStore) {
val value = getState(key).value
LaunchedEffect(key) {
// Restore saved value from dataStore on first access
}
LaunchedEffect(key, value) {
// Flush updated value to dataStore on change
}
}
}
}
}এটি যখন হয়, তখন এটি LaunchedEffect ব্যবহার করে সেইসব ভ্যালু DataStore-এ সিঙ্ক করে, যখনই সেগুলি পরিবর্তন হয়।
এবং, আমি এখানে যে বিষয়টি উল্লেখ করতে চাই তা হল, StateStore-এর মতো স্ট্যান্ডার্ড কন্টেনার থাকার কারণে আমরা এই ড্রপ-ইন ইন্টিগ্রেশন প্রদান করতে পারি।
DataStore হল প্রথম, যা আমরা দেখছি।
সুতরাং, আমাদের স্টেট ক্যাটেগরিতে ফিরে এসে, আমরা এখন আপনার সম্পূর্ণ অ্যাপ্লিকেশন জুড়ে কীভাবে Compose State ব্যবহার করতে পারেন তার একটি স্পষ্ট ছবি পেয়েছি।
লাইভ স্টেটের জন্য mutableStateOf থেকে শুরু করে স্ক্রিন-লোকাল প্রেজেন্টেশন স্টেটের জন্য SavedState API এবং ক্রস-কম্পোনেন্ট সেশন স্টেটের জন্য StateStore এবং সবশেষে, Flows-এর সাথে ইন্টারঅপারেবিলিটির জন্য collectAsState।
কিন্তু আসলে, ইন্টারঅপারেবিলিটি কোনও একটি ক্যাটেগরিতে সীমাবদ্ধ নয়, যখনই আপনার কম্পোজ স্টেটে অবজার্ভেবল টাইপ ব্রিজ করার প্রয়োজন হবে, তখনই আপনি collectAsState-এর মতো হেল্পার ব্যবহার করতে পারবেন।
তবে স্থায়ী স্টেট-এর জন্য, আমরা অন্য যে বিষয়টি দেখছি তা হল Compose-এর সাথে কাজ করার উপায় অথবা DataStore এবং Room-এর জন্য সরাসরি Compose State এক্সপোজ করা, যাতে আপনাকে এটি করার জন্য নিজের মতো করে আরও ভালো সিনট্যাক্স লিখতে না হয়।
এবং এর সাথে, আমি আনন্দের সাথে ঘোষণা করছি যে Transform, StateStore এবং DataStore ইন্টিগ্রেশন আজ আলফা ভার্সনে উপলভ্য এবং আমরা developer.android.com-এ সব ডকুমেন্টেশন প্রকাশ করেছি এবং GitHub-এ আমাদের একটি AppState Recipes রেপো আছে যা StateStore এবং Transform, দুটিই ব্যবহার করার উদাহরণ দেখায়।
[versions]
appstate = "1.0.0-alpha01"
...
[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }এবং, হ্যাঁ, আপনার অ্যাপে এগুলি ব্যবহার করে দেখুন।
আপনার মতামত আমাদের জানান।
যেমন, আমরা আলফা ভার্সনে আছি, তাই এই সময় আমরা সত্যিই পুনরাবৃত্তি করতে পারি এবং পরিবর্তন করতে পারি যা নিশ্চিত করবে যে সেগুলি আপনার ব্যবহারের ক্ষেত্রে কাজ করবে।
/**
* A value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value.
*/
@Stable
public interface State<out T> {
public val value: T
}/**
* A mutable value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value. When the
* [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
* will be scheduled. If [value] is written to with the same value, no recompositions will be
* scheduled.
*/
@Stable
public interface MutableState<T> : State<T> {
override var value: T
public operator fun component1(): T
public operator fun component2(): (T) -> Unit
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}
@Composable
fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Add")
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(true) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(false) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Header(levelName = state.levelName)
ScoreBadge(
timeSurvived = state.timeSurvived,
score = state.score
)
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state = viewModel.uiState
Column {
Header(state = state)
ScoreBadge(state = state)
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: StateFlow<FocusSummary> =
combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle,
)
}class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: StateFlow<FocusSummary> =
combine(
sessionFlow,
taskRepository.tasksFlow
) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
) {
val session by sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow
.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: StateFlow<MiniTimerUiState> =
combine(
focusManager.summary,
isExpanded
) { summary, expanded ->
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MiniTimerUiState()
)
fun toggleExpanded() { isExpanded.update { !it } }
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: State<MiniTimerUiState> =
transform(
initialValue = MiniTimerUiState(),
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val summary by focusManager.summary
val expanded by isExpanded.collectAsState()
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}
fun toggleExpanded() { isExpanded.update { !it } }
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R> {
val transformScope = TransformScope()
val transform = transformScope
.launchTransform(initialValue, onUpdate)
scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
) {
try { transformScope.run() } finally {
transform.dispose()
}
}
return transform
}public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
public abstract class TransformScope {
public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}
public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>
public interface DisposableState<T> : State<T> {
public fun dispose()
}override suspend fun run() {
val context = currentCoroutineContext()
val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
...
val recomposer = Recomposer(context)
val composition = Composition(UnitApplier, recomposer)
try {
withContext(clock) {
if (clock is HeadlessTransformClock) {
launch(start = CoroutineStart.UNDISPATCHED) {
clock.runClock()
}
}
...
}
} finally {
// dispose of composition, cancel the clock, close the recomposer
}
}@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
fun updateSearchQuery(query: String) {
Snapshot.withMutableSnapshot {
searchQuery = query
selectedTaskIds = emptySet()
}
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { _isOnline.value = true }
override fun onLost(network: Network) { _isOnline.value = false }
}
init {
connectivityManager.registerDefaultNetworkCallback(callback)
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
if (!networkMonitor.isOnline.value) {
return@transform TaskListState.Offline
}
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}sealed interface FocusSession {
data object Idle : FocusSession
data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}fun startFocus(taskId: String, durationMinutes: Int = 25) {
val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
stateStore.setState(
FocusSessionKey,
FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
)
}
fun endFocus() {
stateStore.setState(FocusSessionKey, FocusSession.Idle)
}fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Running) return@updateState session
FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
}
}
fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Paused) return@updateState session
FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
}
}@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}
@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
val session by viewModel.session
if (session !is FocusSession.Idle) {
TimerReadout(session = session)
}
}@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
): StateStore = StateStore().apply {
val path = context.dataStoreFile("app_state.json").absolutePath
scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
}
}@Serializable
sealed interface FocusSession {
@Serializable data object Idle : FocusSession
@Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
@Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
PersistToDataStoresuspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener {
for (key in keys) {
if (key is PersistToDataStore) {
val value = getState(key).value
LaunchedEffect(key) {
// Restore saved value from dataStore on first access
}
LaunchedEffect(key, value) {
// Flush updated value to dataStore on change
}
}
}
}
}[versions]
appstate = "1.0.0-alpha01"
...
[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }