Session 1 of 3 · The idea
Query Client
screenهایی بدون ViewModel، در Jetpack Compose
۱ · The idea۲ · The engine۳ · Building
Parisa Hazhir Ghader parisaghader.org
Three sessions
سه جلسه، یک معماری
SESSION 1 · TODAYThe idea
چرا screen به ViewModel نیاز ندارد؟ ایده، جعبهی مشترک، و سه ابزار.
SESSION 2 · NEXT MONDAYThe engine
درِ جعبه را باز میکنیم: کِی fetch، کِی پاک، با error چه میکند.
SESSION 3Building
معماری کامل، recipe، و ساختن یک feature واقعی با هم.
Heads up · a simple story
قبل از شروع، یک داستان ساده: تابلوی دفتر
WITHOUT A BOARD
هر کس خودش به بانک زنگ میزند
همکار ۱ ← تماس با بانک
همکار ۲ ← تماس با بانک
همکار ۳ ← تماس با بانک
سه تماس برای یک عدد. و بعد از یک خرید، هر کس عدد قدیمی خودش را دارد.
WITH A BOARD
یک نفر میپرسد، همه تابلو را میخوانند
بعد از خرید، تابلو پاک میشود و فقط یک بار دوباره پرسیده میشود.
تابلو = cache
خواندن تابلو = query
خرید = mutation
پاک کردن تابلو = invalidate
Heads up · React Query in one minute
اسم این ایده در وب: React Query
useQuery(key, fetch)
only when needed
QUERYخواندن data؛ مثلاً لیست خبرها یا موجودی.
MUTATIONعوض کردن data؛ مثلاً خرید یا ویرایش.
CACHEیک کپی مشترک از data که خودش تازه میشود.
یک library معروف در دنیای وب (TanStack Query، از ۲۰۱۹). امروز همین ایده را در Compose میبینیم.
Heads up · what the code looks like
در کد، فقط دو چیز میگویید
const { data, isLoading, error } = useQuery({ queryKey: ['balance', 'USDT'], queryFn: () => api.getBalance('USDT'),})
1 · QUERYKEYچه dataای؟
اسم data، مثل اسم روی تابلو: «موجودی USDT».
2 · QUERYFNاز کجا بگیرم؟
تابعی که از سرور میگیرد؛ مثل تماس با بانک.
3 · WHAT YOU GETچه چیزی پس میگیرید؟
data: خود data · isLoading: هنوز در راه است · error: اگر نشد.
همین. بقیهی کارها را خودش انجام میدهد.
Heads up · what it does for you
و بقیه را خودش انجام میدهد
CACHEیادش میماند
data را نگه میدارد؛ دفعهی بعد فوراً نشانش میدهد.
DE-DUPLICATIONفقط یک بار میپرسد
اگر سه screen با هم بخواهند، فقط یک request.
REFETCHوقتی کهنه شد، دوباره میپرسد
data قدیمی را نشان میدهد تا جدید برسد.
RETRYاگر نشد، دوباره امتحان میکند
مثلاً وقتی اینترنت یک لحظه قطع شد.
INVALIDATEبعد از تغییر، خبردار میشود
بعد از خرید، موجودی همهجا خودش عوض میشود.
YOUشما فقط میگویید چه dataای
امروز همین ایده را در Compose میسازیم.
Question
الان اپ ما چند کپی از موجودی کیف پول دارد؟
DashboardViewModel
WalletViewModel
BuyViewModel
3 ViewModel · 3 request · برای یک عدد
خرید انجام شد. سرور 1,230 دارد؛ هر سه screen هنوز 1,250.
The idea
server data
مال شما نیست.
یک کپی است — و کپیها را باید مثل cache مدیریت کرد.
Today
امروز سه چیز را میسازیم
QueryEntryجعبه
یک جعبه برای یک تکه server data، مشترک بین همهی screenها.
keyاسم جعبه
مثل "wallet.balance.USDT"؛ screen با آن جعبهاش را پیدا میکند.
QueryClientجای جعبهها
یک map که همهی جعبهها را با اسمشان نگه میدارد.
تا آخر جلسه، هر screen فقط میگوید چه dataای میخواهد.
Before React Query · ~2019
وب همین مشکل را داشت
articles
action: LOADING
action: SUCCESS
action: FAILURE
reducer
useEffect → load()
profile
action: LOADING
action: SUCCESS
action: FAILURE
reducer
useEffect → load()
orders
action: LOADING
action: SUCCESS
action: FAILURE
reducer
useEffect → load()
آشنا نیست؟ این همان UiState + ViewModel + LaunchedEffect ماست.
TanStack Query · Tanner Linsley · 2019
server data، state نیست؛ cache است.
const { data, isLoading, error } = useQuery({
queryKey: ['articles', source],
queryFn: () => api.getHeadlines(source),
})
cache، مشترک بین componentها
فقط یک request همزمان
refetch بعد از تغییر
retry و polling
Why Android
Compose همان مدل React است
useMemo / useRef
→
remember
useEffect
→
DisposableEffect
custom hook
→
@Composable fun useX()
پس hook در Compose طبیعی جا میافتد. ما library ی React را نمیآوریم؛ فقط ایده را.
Two kinds of state
دو جور state داریم
SCREEN STATEمال screen است
تب انتخابشده، متن یک فرم، باز بودن یک bottom sheet.
خودمان نوشتیم، پس همیشه درست است.
SERVER DATAمال سرور است
موجودی کیف پول، سفارشهای باز، قیمتها.
یک کپی است: stale میشود، مشترک است، بعد از هر تغییر باید refetch شود.
ViewModel برای اولی عالی است. برای دومی یک cache مشترک لازم داریم.
One shared box · QueryEntry
سه screen. یک جعبه.
key = "wallet.balance.USDT"
QueryEntry
data1,250 USDT
observers3 screens
requests1
یک request، و هر سه screen همان را میخوانند.
خرید → invalidate → همه با هم عوض میشوند
Inside the box
داخل جعبه چه هست؟
QueryEntry · "wallet.balance.USDT"
the box
dataStateFlow
observers3
lastFetch10:42
inFlight—
dataخود data، در یک StateFlow که همهی screenها میخوانند.
observersچند screen الان دارند نشانش میدهند.
lastFetchآخرین بار کِی از سرور گرفته شد.
inFlightrequest در جریان، اگر الان یکی در راه است.
قانونهای این چهار تا؟ جلسهی بعد.
key
key یعنی اسم جعبه
"wallet.balance.USDT"
→
موجودی USDT
"orders.open"
→
سفارشهای باز
"articles.headlines.techcrunch.2"
→
صفحهی ۲ خبرهای TechCrunch
قانون: هر چیزی که نتیجه را عوض میکند، باید در key باشد.
// bug: page is not in the key
useQuery(key = "headlines") {
repo.getHeadlines(source, page)
} // page 2 shows page 1
// fix
useQuery(key = ArticleKeys.headlines(source, page)) {
repo.getHeadlines(source, page)
}
key · two more rules
دو قانون دیگر برای key
RULE 2 · HIERARCHICAL, LIKE FOLDERS
articles
├── headlines
│ ├── techcrunch.1
│ └── techcrunch.2
└── bookmarks
یک گروه کامل را با هم refetch کن:
queryClient.invalidateMatching("articles\\..*")
یعنی: «هر key که با articles. شروع میشود.»
RULE 3 · NO HAND-TYPED STRINGS
// hook reads
useQuery(key = "wallet.balance.usdt") { ... }
// buy invalidates
invalidateKeys = listOf("wallet.balance.USDT")
دو رشتهی متفاوت = دو جعبه. خرید موفق، ولی موجودی هیچوقت تازه نمیشود؛ بدون هیچ error.
object WalletKeys {
fun balance(symbol: String) = "wallet.balance.$symbol"
}
// hook + mutation: WalletKeys.balance("USDT")
یک تعریف، یک جا. typo دیگر ممکن نیست.
QueryClient
QueryClient: یک map از key به جعبه
@Singleton
class QueryClient {
val entries =
ConcurrentHashMap<String, QueryEntry>()
fun getOrCreate(key: String) =
entries.computeIfAbsent(key) {
QueryEntry(key)
}
}
key هست؟
همان جعبه را برمیگرداند. پس screen دوم جعبهی screen اول را میگیرد.
key نیست؟
یک جعبهی جدید میسازد و در map میگذارد.
یک Singleton در Hilt
کل اپ یک map دارد. برای همین data بین screenها مشترک است.
Before · After
یک screen، دو خط.
30خط · ViewModel + UiState
data class HeadlinesUiState(
val isLoading: Boolean = false,
val articles: List<Article> = emptyList(),
val error: String? = null,
)
@HiltViewModel
class HeadlinesViewModel @Inject constructor(
private val repo: ArticleRepository,
) : ViewModel() {
private val _state = MutableStateFlow(HeadlinesUiState())
val state = _state.asStateFlow()
fun load() = viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
runCatching { repo.getHeadlines() }
.onSuccess { l -> _state.update { ... } }
.onFailure { e -> _state.update { ... } }
}
}
@Composable
fun HeadlinesScreen(vm: HeadlinesViewModel = hiltViewModel()) {
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { vm.load() }
when { ... }
}
2خط · hook
val headlines = useHeadlines()
QueryContent(headlines) {
ArticleList(it)
}
بدون ViewModel · بدون UiState · بدون load() · بدون boolean loading
In production
این فقط یک ایده نیست
4اپ white-label روی یک engine
یک اپ معاملاتی رمزارز، بیش از یک سال در production. ComposeCleanArch نسخهی تمیز و تستشدهی همان engine است.
A hook, line by line
یک hook، خط به خط
@Composablefun useHeadlines(source: String, page: Int = 1) : QueryResult<List<Article>> { val provider = hiltViewModel<HookProvider>() return useQuery( key = ArticleKeys.headlines(source, page), fetcher = { provider.articleRepository .getHeadlines(source, page) }, )} // the screenval headlines = useHeadlines("techcrunch")QueryContent(headlines) { ArticleList(it) }
1 · THE HOOKیک تابع @Composable که UI نمیکشد؛ یک مقدار برمیگرداند. اسمش با use شروع میشود.
2 · HOOKPROVIDERراه رسیدن به repositoryهای Hilt از داخل Compose. یک @HiltViewModel بدون state و بدون منطق.
3 · KEYکدام جعبه؟ "articles.headlines.techcrunch.1". source و page در key هستند چون نتیجه را عوض میکنند.
4 · FETCHERجعبه را از کجا پر کنیم؟ یک lambda که repository را صدا میزند؛ data برمیگرداند یا throw میکند.
5 · THE SCREENدو خط. hook جعبه را میدهد؛ QueryContent وقتی data رسید لیست را نشان میدهد.
QueryState · QueryContent
جعبه همیشه در یکی از سه حالت است
یک sealed interface؛ سه حالت، نه هشت ترکیب از سه boolean. QueryContent برای هر حالت یکی از اینها را میکشد.
useMutation · invalidate
write: بعد از خرید، همهجا عوض میشود
val buy = useMutation<BuyRequest, Order>(
mutationFn = { req -> repo.buy(req) },
options = MutationOptions(
invalidateKeys = listOf(
WalletKeys.balance("USDT")),
),
)
Button(onClick = { buy(request) }) { Text("Buy") }
1 · BUYrequest به سرور؛ سفارش ثبت شد.
2 · INVALIDATEجعبهی wallet.balance.USDT «کهنه» علامت میخورد.
3 · REFETCHهر screen که موجودی را نشان میدهد، خودکار تازه میشود.
screen خرید نمیداند screen کیف پول وجود دارد. فقط میگوید کدام data عوض شد.
Where it lives
هر تکه کجا زندگی میکند
Hook · useHeadlines()
:app/hooks@Composable
QueryClient · QueryEntry
:core:querypure Kotlin
Repository
:core:networkpure Kotlin
Api · Retrofit
:core:networkpure Kotlin
فقط دو لایهی بالا Composable هستند. engine Kotlin خالص است؛ پس بدون emulator test میشود.
Recap
امروز سه چیز یاد گرفتیم
1 server data مال ما نیست؛ یک کپی است.
2 یک جعبهی مشترک، با یک اسم، در یک map: QueryEntry · key · QueryClient
3 screen = یک hook + QueryContent. بدون ViewModel.
Next Monday · Session 2 · The engine
جعبه کِی به سرور میرود؟
کِی پاک میشود؟ با error چه میکند؟ اگر دو request با هم برسند؟
HOMEWORK
در ComposeCleanArch این دو فایل را بخوانید: ArticleHooks.kt و ArticleListScreen.kt.
و به این فکر کنید: اگر دو screen همزمان useHeadlines("techcrunch") را صدا بزنند، چند request میرود؟
Q&A
سؤال؟
github.com/lumoradevlab/ComposeCleanArch
Parisa Hazhir Ghader · parisaghader.org