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 · TODAY

The idea

چرا screen به ViewModel نیاز ندارد؟ ایده، جعبه‌ی مشترک، و سه ابزار.

SESSION 2 · NEXT MONDAY

The engine

درِ جعبه را باز می‌کنیم: کِی fetch، کِی پاک، با error چه می‌کند.

SESSION 3

Building

معماری کامل، recipe، و ساختن یک feature واقعی با هم.

Heads up · a simple story

قبل از شروع، یک داستان ساده: تابلوی دفتر

WITHOUT A BOARD

هر کس خودش به بانک زنگ می‌زند

همکار ۱ ← تماس با بانک
همکار ۲ ← تماس با بانک
همکار ۳ ← تماس با بانک

سه تماس برای یک عدد. و بعد از یک خرید، هر کس عدد قدیمی خودش را دارد.

WITH A BOARD

یک نفر می‌پرسد، همه تابلو را می‌خوانند

BOARD
موجودی: 1,250

بعد از خرید، تابلو پاک می‌شود و فقط یک بار دوباره پرسیده می‌شود.

تابلو = cache خواندن تابلو = query خرید = mutation پاک کردن تابلو = invalidate
Heads up · React Query in one minute

اسم این ایده در وب: React Query

SCREEN
Screen
useQuery(key, fetch)
SHARED COPY
cache
only when needed
BACKEND
Server
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

الان اپ ما چند کپی از موجودی کیف پول دارد؟

Dashboard
1,250
USDT
Wallet
1,250
USDT
Buy
1,250
USDT
DashboardViewModel
WalletViewModel
BuyViewModel
SERVER
1,250
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
Context
→
CompositionLocal
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. یک جعبه.

Dashboard
1,250
USDT
Wallet
1,250
USDT
Buy
1,250
USDT
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

آخرین بار کِی از سرور گرفته شد.

inFlight

request در جریان، اگر الان یکی در راه است.

قانون‌های این چهار تا؟ جلسه‌ی بعد.
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

این فقط یک ایده نیست

170+

hook

76

useQuery

56

useMutation

4

اپ white-label روی یک engine

یک اپ معاملاتی رمزارز، بیش از یک سال در production. ComposeCleanArch نسخه‌ی تمیز و تست‌شده‌ی همان engine است.

Three tools

screen فقط سه ابزار لازم دارد

READ

useQuery

«این data را می‌خواهم.» key + یک fetcher. cache و refetch و retry با خودش.

WRITE

useMutation

«این را عوض کن.» خرید، ویرایش، حذف. بعد از موفقیت keyهای مربوط را invalidate می‌کند.

RENDER

QueryContent

«وقتی رسید، این را نشان بده.» loading و error را خودش می‌کشد.

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

جعبه همیشه در یکی از سه حالت است

Loading
Success
Couldn't load
Retry
Failure

یک 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 · BUY

request به سرور؛ سفارش ثبت شد.

2 · INVALIDATE

جعبه‌ی wallet.balance.USDT «کهنه» علامت می‌خورد.

3 · REFETCH

هر screen که موجودی را نشان می‌دهد، خودکار تازه می‌شود.

screen خرید نمی‌داند screen کیف پول وجود دارد. فقط می‌گوید کدام data عوض شد.
Where it lives

هر تکه کجا زندگی می‌کند

Screen

:app/ui
@Composable

Hook · useHeadlines()

:app/hooks
@Composable

QueryClient · QueryEntry

:core:query
pure Kotlin

Repository

:core:network
pure Kotlin

Api · Retrofit

:core:network
pure 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

1 / 1