Jetpack Compose Android · Architecture

معماری Query Client

screenهایی بدون ViewModel — cache برای server data به سبک React Query، دست‌نویس برای Compose

Parisa Hazhir Ghaderparisaghader.org
الهام از React Query ComposeCleanArch
دستور جلسه Agenda

امروز چه می‌بینیم

01

ایده از کجا آمد

React Query: چه مشکلی را حل کرد و چرا به Compose آمد

02

مشکل

چرا ViewModel برای server data زیادی است

03

ایده در Compose

یک جعبه‌ی مشترک، key، و سه ابزار: useQuery، useMutation، QueryContent

04

Engine

قانون‌های جعبه: staleTime، observers، fetch، invalidate، retry، polling — با simulator

05

Traps

آنچه یک پیاده‌سازی ساده اشتباه می‌کند

06

شروع کار

recipe ساخت feature، rules و یک تمرین عملی

مقصد Where we are going

مقصد امروز، در یک جمله

server data مال ما نیست؛ یک کپی است. پس به جای اینکه هر screen کپی خودش را در یک ViewModel نگه دارد، یک cache مشترک داریم و screen فقط می‌گوید چه data‌ای می‌خواهد.

QueryEntry

جعبه

یک جعبه برای یک تکه server data، مشترک بین همه‌ی screenها.

key

اسم جعبه

مثل "wallet.balance.USDT"؛ screen با آن جعبه‌اش را پیدا می‌کند.

QueryClient

جای جعبه‌ها

یک map که همه‌ی جعبه‌ها را با اسمشان نگه می‌دارد.

هر چیز دیگری امروز — staleTime، retry، invalidate — فقط یک قانون درباره‌ی همین جعبه است.

01
بخش اول Where it comes from

React Query

قبل از هر خط کد اندرویدی: این ایده در دنیای وب چه مشکلی را حل کرد، و چرا به Compose می‌آید.

قبل از React Query Redux era · ~2019

مشکلی که وب داشت

// Redux — one request, by hand
dispatch({ type: 'ARTICLES_LOADING' })
try {
  const data = await api.getHeadlines(source)
  dispatch({ type: 'ARTICLES_SUCCESS', data })
} catch (e) {
  dispatch({ type: 'ARTICLES_FAILURE', error: e.message })
}

function articlesReducer(state, action) {
  switch (action.type) {
    case 'ARTICLES_LOADING':
      return { ...state, isLoading: true, error: null }
    case 'ARTICLES_SUCCESS':
      return { ...state, isLoading: false, articles: action.data }
    case 'ARTICLES_FAILURE':
      return { ...state, isLoading: false, error: action.error }
  }
}

useEffect(() => { loadArticles() }, [])

آشنا نیست؟

action + reducer + isLoading در وب همان UiState + ViewModel + load() ماست، و useEffect همان LaunchedEffect.

و همان مشکل‌ها

  • کد تکراری برای هر request
  • stale data، بدون cache واقعی
  • request تکراری از چند component
  • screen‌ای که بعد از ویرایش refetch نمی‌شود
ایده TanStack Query · Tanner Linsley

Server data، state نیست؛ cache است

// React Query
const { data, isLoading, error } = useQuery({
  queryKey: ['articles', source, page],
  queryFn: () => api.getHeadlines(source, page),
})

const bookmark = useMutation({
  mutationFn: (id) => api.bookmark(id),
  onSuccess: () =>
    queryClient.invalidateQueries({ queryKey: ['bookmarks'] }),
})

component فقط می‌گوید «چه data‌ای» و «از کجا». بقیه کار با یک QueryClient مرکزی است.

یک cache مرکزی، یک بار و درست

  • cache با key، مشترک بین componentها
  • de-duplication requestها
  • stale-while-revalidate: data قبلی را نشان بده، پشت صحنه refetch کن
  • retry، polling و invalidate بعد از write
  • پاک کردن data‌ای که کسی نگاهش نمی‌کند

component دیگر server data را «مدیریت» نمی‌کند؛ فقط آن را «request» می‌کند.

چرا اندروید Why bring it to Compose

چرا این ایده را به اندروید آوردیم

Compose همان مدل React است

ReactCompose
useMemo / useRefremember
useEffectLaunchedEffect / DisposableEffect
ContextCompositionLocal
custom hook@Composable fun useX()

مشکل یکی است

ViewModel + StateFlow همان Redux + thunk است: هر screen cache خودش را می‌سازد.

option‌های اندروید حلش نمی‌کردند

Store5 cache را در Repository می‌گذارد و از screen روی صفحه خبر ندارد؛ Apollo فقط برای GraphQL است.

کوچک و مال خودمان

حدود ۸۸۰ خط Kotlin با Coroutines و StateFlow، بدون dependency بیرونی، با ۱۲ test.

02
بخش دوم The problem

یک screen ساده، این همه کد

بیشتر screenهای ما فقط data‌ای را از سرور نشان می‌دهند. اما برای هر کدام یک ViewModel، یک UiState و یک load() می‌نویسیم.

مقایسه MVVM vs hooks

همان screen، دو روش

روش معمول · MVVM~30 lines
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, error = null) }
        runCatching { repo.getHeadlines() }
            .onSuccess { l -> _state.update { it.copy(isLoading = false, articles = l) } }
            .onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message) } }
    }
}
@Composable
fun HeadlinesScreen(vm: HeadlinesViewModel = hiltViewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    LaunchedEffect(Unit) { vm.load() }
    when {
        state.isLoading -> Loading()
        state.error != null -> ErrorView(state.error!!, onRetry = vm::load)
        else -> ArticleList(state.articles)
    }
}
با Query ClientArticleListScreen.kt
@Composable
fun 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)
        },
    )
}

@Composable
fun ArticleListScreen() {
    val headlines = useHeadlines(DEFAULT_SOURCE)
    QueryContent(headlines) { articles -> ArticleList(articles) }
}

و نسخه‌ی MVVM هنوز cache ندارد، data را بین screenها به اشتراک نمی‌گذارد، با هر ورود دوباره request می‌زند و retry ندارد.

مشکل Two screens, two copies

دو screen، دو کپی از یک data

class WalletViewModel(...) : ViewModel() {
    val balance = MutableStateFlow<String?>(null)

    fun load() = viewModelScope.launch {
        balance.value = repo.getBalance()
    }
}

class DashboardViewModel(...) : ViewModel() {
    val balance = MutableStateFlow<String?>(null)

    fun load() = viewModelScope.launch {
        balance.value = repo.getBalance()
    }
}

نتیجه

  • دو جعبه که یک عدد را نگه می‌دارند
  • دو request برای یک چیز
  • بعد از خرید، هر دو عدد قدیمی را نشان می‌دهند تا دستی بگوییم reload کنند
  • و هیچ‌کدام cache یا retry ندارند

مشکل فقط تعداد خط نیست: هر ViewModel یک کپی جدا از یک data دارد، و ما مسئولیم این کپی‌ها را دستی هماهنگ نگه داریم.

ایده‌ی کلیدی Server state ≠ client state

Server data با screen state فرق دارد

ویژگیClient stateServer state
مالکخود screenسرور — ما فقط یک کپی داریم
stale شدنstale نمی‌شودهر لحظه ممکن است stale شود
اشتراکمعمولاً یک screenچند screen همان data را می‌خواهند
چه لازم داردنگه‌داری سادهcache، refetch، de-duplication، retry، invalidate
مثالمتن فرم، تب انتخاب‌شده، باز بودن bottom sheetموجودی کیف پول، سفارش‌های باز، قیمت‌ها

ViewModel برای screen state ابزار خوبی است. اما برای server data، هر screen دوباره همان cache ناقص را از نو می‌نویسد.

03
بخش سوم The idea, in Compose

همان ایده، در Compose

یک cache layer مشترک برای همه‌ی server data؛ screen فقط می‌گوید «چه data‌ای» و «success state چه شکلی است».

ایده One shared box · QueryEntry

یک جعبه‌ی مشترک برای هر data

Wallet← همان جعبه را می‌خواند
Dashboard← همان جعبه را می‌خواند
Buy← همان جعبه را می‌خواند
QueryEntry · key = "wallet.balance.USDT"
data
StateFlow · 1,250 USDT
observers
۳ screen
lastFetch
10:42
in-flight request
ندارد

یک جعبه، پس عدد هیچ‌وقت بین screenها فرق نمی‌کند.

یک request جعبه را پر می‌کند و همه نتیجه را می‌بینند.

بعد از خرید، همین یک جعبه refresh می‌شود و همه‌ی screenها با هم عوض می‌شوند.

اسم و جای جعبه key · QueryClient

هر جعبه یک اسم دارد؛ همه در یک map

@Singleton
class QueryClient @Inject constructor(...) {
    private val entries =
        ConcurrentHashMap<String, QueryEntry<*>>()

    fun getOrCreate(key: String, ...) =
        entries.computeIfAbsent(key) { QueryEntry(key, ...) }
}
object ArticleKeys {
    fun headlines(source: String, page: Int) =
        "articles.headlines.$source.$page"
}

key هست؟

همان جعبه را برمی‌گرداند، پس screen دوم جعبه‌ی screen اول را می‌گیرد. نیست؟ یک جعبه‌ی جدید می‌سازد.

سه قانون key

  • هر چیزی که نتیجه‌ی request را عوض می‌کند (page، فیلتر، id) باید در key باشد
  • key سلسله‌مراتبی است، تا یک گروه را با هم invalidate کنیم
  • keyها در یک object XxxKeys کنار Api؛ نه رشته‌ی دستی
Mapping React Query → ComposeCleanArch

از React به Compose، تکه به تکه

React QueryComposeCleanArchتفاوت و دلیل
QueryClient + QueryClientProviderQueryClient (@Singleton) + LocalQueryClientDI مالک cache است
useQuery({ queryKey, queryFn })useQuery(key, fetcher, options)همان contract
['articles', id]"articles.headlines.$id"invalidate گروهی با regex
staleTime default ۰staleTime default ۵ دقیقهموبایل: باتری و اینترنت گران‌تر است
gcTime (قبلاً cacheTime)، ۵ دقیقهcacheTime، ۱۰ دقیقههمان مفهوم
retry ۳ بار، روی هر error۳ attempt، فقط transient error4xx تکرار نمی‌شود
refetchOnWindowFocuspolling فقط در STARTEDlifecycle اندروید جای focus پنجره
isLoading / SuspenseQueryState sealed + QueryContentcompiler case جاافتاده را می‌گیرد
useMutation + invalidateQueriesuseMutation + invalidateKeyswrite single-flight، بدون retry default
Primitives The primitives

سه primitive، یک data flow

read

useQuery

یک key و یک fetch function. cache، freshness، de-duplication و retry را خودش انجام می‌دهد.

write

useMutation

ثبت، حذف، ویرایش. بعد از موفقیت، keyهای مربوط را invalidate می‌کند تا screenها refetch شوند.

render

QueryContent

Loading و Error را یک‌جا render می‌کند. screen فقط success state را توصیف می‌کند.

engine

QueryClient

یک @Singleton در Hilt؛ مالک واقعی cache. برای invalidate بین featureها، setData و prefetch.

val headlines = useHeadlines()
QueryContent(headlines) { list -> ArticleList(list) }

// no ViewModel · no UiState · no LaunchedEffect { load() }
// no loading boolean · no error string

الهام از TanStack Query (React Query)؛ دست‌نویس با Coroutines و StateFlow — بدون Store5 و بدون library بیرونی.

معماری Data flow

Data flow، از screen تا network

Screen · Composable

فقط success state را می‌سازد

:app/ui
@Composable
↓

Hook · useHeadlines()

key + repository از طریق HookProvider

:app/hooks
@Composable
↓

QueryClient → QueryEntry

cache، staleTime، de-duplication، retry، polling، evict

:core:query
Kotlin خالص · testable
↓

Repository

Resource را باز می‌کند؛ domain model برمی‌گرداند یا throw می‌کند

:core:network
Kotlin خالص
↓

Api (Retrofit) + Resource call adapter

هر responseی — ۲xx، error با هر شکلی، JSON خراب، connection drop — بدون throw به Resource تبدیل می‌شود. safeApiCall لازم نیست.

:core:network
Kotlin خالص
ساختار 9 modules · downward only

۹ module، dependency فقط رو به پایین

:app
↓
:core:ui:core:query:core:network
:core:datastore:core:database:core:designsystem
↓
:core:common
↓
:core:model
:core:testing

بدون feature module

همه‌ی UI در :app، همه‌ی data در :core:network. plugin convention برای روزی که یک feature واقعاً بزرگ شود آماده است.

:core:model برگ خالص است

بدون Android، Retrofit یا Gson. mapperهای DTO→model در :core:network هستند، چون به DTO اشاره می‌کنند — وگرنه circular dependency می‌شد.

state QueryState

یک state، فقط سه حالت

sealed interface QueryState<out T> {
    data object Loading : QueryState<Nothing>
    data class Success<out T>(
        val data: T,
        val refreshing: Boolean = false,
    ) : QueryState<T>
    data class Failure(val error: AppError) : QueryState<Nothing>
}
when (state) {
    is QueryState.Loading -> AppLoading()
    is QueryState.Failure -> AppErrorState(state.error.message, onRetry)
    is QueryState.Success -> content(state.data)
}   // ← this is QueryContent

نسخه‌ی ساده: سه فیلد جدا

سه فیلد جدا: data، error، isLoading. یعنی ۸ combination، که بعضی‌شان بی‌معنی‌اند — data و error با هم؟

ComposeCleanArch: sealed

دقیقاً یکی از سه حالت. when کامل است و compiler case جاافتاده را یادآوری می‌کند.

refreshing = true

stale-while-revalidate: data روی صفحه می‌ماند و spinner چشمک نمی‌زند، تا response جدید برسد.

04
بخش چهارم The engine

زیر کاپوت

هر key یک QueryEntry دارد. بیایید با simulator ببینیم در طول زمان با آن چه اتفاقی می‌افتد.

قانون ۱ staleTime · lastFetch

جعبه کِی به سرور می‌رود؟

داخل جعبهکار جعبه
خالیبه سرور برو
data دارد و fresh استهمان را بده؛ request نزن
data دارد ولی stale استبه سرور برو
private fun shouldFetch(): Boolean =
    current == null ||                  // empty box
    (now() - lastFetch) > staleTime     // or old data

staleTime = ۵ دقیقه (default)

۲ دقیقه پیش گرفته شده: fresh، بدون request.
۷ دقیقه پیش: stale، دوباره fetch.

اشتباه رایج

stale شدن به‌تنهایی request نمی‌زند. جعبه فقط علامت می‌خورد؛ دفعه‌ی بعد که screenی بازش کند refetch می‌شود. data زنده = refetchInterval.

قانون ۲ observers · cacheTime

جعبه می‌شمارد چه کسی نگاهش می‌کند

fun observe() {
    observers++
    cancelEvictTimer()
    if (observers == 1 && shouldFetch()) fetch()
}

fun unobserve() {
    observers--
    if (observers == 0) startEvictTimer(cacheTime)
}
// inside useQuery
DisposableEffect(key) {
    entry.observe()                    // screen appeared: +1
    onDispose { entry.unobserve() }    // screen left: -1
}
Wallet opens → observers = 1 (fetch)
Dashboard opens → observers = 2 (no request)
Wallet closes → observers = 1
Dashboard closes → observers = 0 (evict timer)

دو زمان، کنار هم

staleTime (۵ دقیقه): تا کی data fresh است.
cacheTime (۱۰ دقیقه): جعبه‌ای که کسی نگاهش نمی‌کند تا کی نگه داشته می‌شود.

Simulator staleTime = 5 min · cacheTime = 10 min

lifecycle یک key در cache

key = "articles.headlines.techcrunch.1"
state
—
data
—
simulated time
۰ دقیقه
screenهای بازِ این key
۰
network requestها
۰
حذف از cache
—
سن data نسبت به staleTime—
قانون ۳ fetch · de-duplication

fetch: سه کار، و فقط یک request در هر لحظه

۱. نشان بده که load می‌کنی

جعبه خالی: Loading. data دارد: Success(old, refreshing)؛ spinner نمی‌آید

↓

۲. fetcher را صدا بزن

همان lambdaی که در hook دادید

↓

۳. نتیجه را ذخیره کن

موفق: Success(new) و lastFetch = now. ناموفق: Failure، یا اگر data قبلی بود همان می‌ماند

fun fetch() {
    if (inFlight != null) return     // already fetching → join it

    inFlight = scope.launch {
        state = if (data == null) Loading
                else Success(data, refreshing = true)
        try {
            val newData = fetcher()
            state = Success(newData)
            lastFetch = now()
        } catch (e: Exception) {
            state = if (data != null) Success(data)
                    else Failure(AppError.from(e))
        } finally {
            inFlight = null
        }
    }
}
Simulator Request de-duplication

سه screen، یک request

useWalletBalance("USDT") → key "wallet.balance.USDT.spot"

داشبورد—
کیف پول—
Header موجودی—

network

۰ request

QueryEntry برای هر key فقط یک in-flight request نگه می‌دارد؛ بقیه به همان وصل می‌شوند و همه یک StateFlow مشترک را می‌بینند.

write useMutation + invalidateKeys

Write، و refetch خودکار screenها

val buy = useMutation<BuyRequest, Order>(
    mutationFn = { request -> repo.buy(request) },
    options = MutationOptions(
        invalidateKeys = listOf(WalletKeys.balance("USDT")),
    ),
)

Button(onClick = { buy(request) }, enabled = !buy.isLoading) {
    Text("Buy")
}
// QueryEntry
fun invalidate() {
    lastFetch = 0L                  // now counts as old
    if (observers > 0)
        fetch(force = true)         // someone is watching
}

۱. کاربر دکمه را می‌زند

mutate فقط یک بار اجرا می‌شود (single-flight)

↓

۲. request به API

error به AppError تبدیل می‌شود؛ retry فقط برای transient error

↓

۳. موفق → invalidate(keys)

keyهایی که یک screen باز دارند همین حالا refetch می‌شوند

↓

۴. بقیه فقط stale می‌شوند

دفعه‌ی بعد که screenشان باز شود، fresh data می‌گیرند

Simulator RetryPolicy.isRetryable()

کدام error ارزش retry دارد؟

روی یک error بزنید:

یک error را انتخاب کنید.

internal fun isRetryable(t: Throwable): Boolean = when (t) {
    is IOException -> true                 // no connection
    is HttpStatusException -> when (t.httpStatusCode) {
        NETWORK_ERROR_CODE -> true
        408 -> true                        // timeout
        in 500..599 -> true                // server error
        else -> false                      // 4xx: same error again
    }
    else -> false
}
// 429 is handled in the network layer, with backoff
Simulator Out-of-order responses · epoch

response قدیمی نباید data جدید را بپوشاند

قدم ۰ از ۴
موجودی در سرور
100
موجودی روی صفحه
100
fetch #1—
fetch #2—
latestEpoch = 0

شروع

Live data refetchInterval

Polling، فقط وقتی کسی نگاه می‌کند

مثالexamples/WalletHooks.kt
return useQuery(
    key = WalletKeys.balance(symbol, market),
    fetcher = { provider.walletRepository.getBalance(symbol, market) },
    options = QueryOptions(
        enabled = enabled && symbol.isNotEmpty(),
        staleTime = 30.seconds.inWholeMilliseconds,
        cacheTime = 1.minutes.inWholeMilliseconds,
        refetchInterval = 15.seconds.inWholeMilliseconds,
    ),
)
  • فقط وقتی screen STARTED است (repeatOnLifecycle) — در background متوقف می‌شود
  • ComposeCleanArch: اگر request قبلی هنوز تمام نشده، این دور رد می‌شود، نه اینکه request جدید زده شود
  • live price با WebSocket و useTickerPrice، نه polling
نوع datainterval پیشنهادی
سفارش‌های باز5s
تاریخچه‌ی معاملات10s
خلاصه‌ی داشبورد30s
موجودی کیف پول15s
اعلان‌ها و پیام‌ها60s

هر چه data سریع‌تر عوض شود، interval کوتاه‌تر؛ برای هر ثانیه، WebSocket.

فراتر از REST WebSocket hooks

همان الگو، برای live price

مثالexamples/TickerHooks.kt
@Composable
fun useTickerPrice(symbol: String): Ticker? {
    val stream = LocalPriceStream.current
    val ticker by remember(symbol) { stream.prices(symbol) }
        .collectAsStateWithLifecycle()
    return ticker
}

@Composable
fun usePriceStreamLifecycle() {
    // ON_START -> stream.resume()
    // ON_STOP  -> stream.pause()
}

hook یعنی «یک contract»

برای screen فرقی ندارد data از REST با cache می‌آید یا از WebSocket: یک function useX() صدا می‌زند و مقدار می‌گیرد.

lifecycle در خود hook

connection در background می‌خوابد و با برگشت کاربر دوباره وصل می‌شود؛ هیچ screen‌ای لازم نیست این را بداند.

offline useCachedQuery · Room tier

وقتی data باید بماند

val profile = useCachedQuery(
    key     = ProfileKeys.PROFILE,
    fetcher = { api.getProfile() },   // network -> ProfileEntity
    reader  = { dao.observe() },      // Room -> Flow<ProfileEntity?>
    writer  = { dao.upsert(it) },
)   // default: QueryOptions.OfflineForever
  • Room source of truth است: مقدار از reader می‌آید و هر fetch موفق با writer ذخیره می‌شود
  • بعد از بسته شدن اپ (process death) و بدون اینترنت هم data هست
  • default OfflineForever: خودبه‌خود stale نمی‌شود؛ فقط با invalidate refetch می‌شود
  • اگر fetch شکست بخورد، همان مقدار ذخیره‌شده نمایش داده می‌شود
  • وقتی کسی نگاه نمی‌کند، اشتراک Room هم قطع می‌شود

بیشتر keyها in-memory هستند (useQuery). Room layer فقط برای data‌ای که واقعاً باید offline بماند.

05
بخش پنجم Traps

Traps: نسخه‌ی ساده

useQuery ساده را می‌شود یک عصره نوشت. کار می‌کند — تا وقتی network کند شود، کاربر سریع بزند، یا اپ ساعت‌ها باز بماند.

مقایسه naive → ComposeCleanArch :core:query

نسخه‌ی ساده در برابر ComposeCleanArch

موضوعنسخه‌ی سادهComposeCleanArch
stateResource با سه فیلد جداQueryState sealed: Loading / Success / Failure
Loading و Errorهر screen خودشQueryContent یک‌جا
retry در readیا همه‌ی errorها، یا هیچ‌کدام۳ attempt، فقط transient error، با delay ۱ و ۲ ثانیه
retry در writeروی هر error — حتی 4xxفقط transient error؛ default بدون retry
دو بار زدن دکمهدو requestsingle-flight: دومی نادیده گرفته می‌شود
late responsedata جدیدتر را می‌پوشاندepoch: response قدیمی دور ریخته می‌شود
refetch ناموفقdata خوب با error جایگزین می‌شودdata خوب روی صفحه می‌ماند
dynamic keysکارها لغو می‌شوند ولی entry در map می‌ماندبعد از cacheTime واقعاً از map حذف می‌شود
polling روی network کندهر دور request جدید، حتی اگر قبلی تمام نشدهدوری که روی in-flight request بیفتد رد می‌شود
test و keyهاDispatchers.IO مستقیم، بدون test؛ key رشته‌ی پراکندهdispatcher تزریقی، ۱۲ engine test؛ object XxxKeys
Simulator Single-flight mutation

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

سریع دو یا سه بار بزنید

in-flight: ۰

سفارش‌های ثبت‌شده در سرور

۰

در نسخه‌ی ساده اگر UI دکمه را به‌موقع غیرفعال نکند، هر ضربه یک coroutine جدا می‌سازد. ComposeCleanArch این را در خود useMutation تضمین می‌کند، نه در هر screen.

06
بخش ششم Getting started

از فردا چطور بنویسیم

Rules، recipe ساخت یک feature، و یک تمرین روی ComposeCleanArch.

مرزها Where a state holder belongs

کِی هنوز state holder لازم است؟

بله، state holder بسازید

  • فرم و validation (ورود، ثبت‌نام، برداشت)
  • جریان چندمرحله‌ای (KYC، تبدیل، خرید سریع)
  • state محلی که باید از تغییر configuration جان سالم به در ببرد

نه، فقط hook

  • screen read-only، لیست، جزئیات
  • data‌ای که چند screen مشترک می‌بینند
  • هر چیزی که فقط «بگیر و نشان بده» است

state holder خودش fetch نمی‌کند. screen state و validation را نگه می‌دارد و همان hookها و mutationها را صدا می‌زند — نه یک راه دوم برای گرفتن data.

Rules ARCHITECTURE.md · hard rules

Ruleهایی که کد را maintainable نگه می‌دارند

یک نوع نتیجه

AppResult و AppError. Resource فقط داخل :core:network؛ از Repository بیرون نمی‌آید.

screen غول نداریم

Composable بالای ~۲۵۰ خط به componentهای کوچک‌تر تقسیم می‌شود.

یک Api برای هر حوزه

نه یک interface با ۲۰۰ endpoint. keyها در object XxxKeys کنار همان Api.

Domain خالص می‌ماند

در :core:model هیچ Android، Retrofit، Compose یا Gson.

dispatcher تزریقی

@IoDispatcher، نه Dispatchers.IO — تا test بتواند TestDispatcher بدهد.

پول با BigDecimal

پشت یک domain type به اسم Money. هرگز Double یا Float برای موجودی و کارمزد.

Recipe Adding a feature · 8 steps

Recipe: ساخت یک feature، قدم به قدم

01

domain model

data class خالص؛ منطق قابل‌محاسبه به‌صورت property

:core:model
02

Api + Keys

XxxApi با خروجی Resource<…> و object XxxKeys

:core:network/apis
03

DTO + mapper

همه‌چیز روی سیم nullable؛ نرمال‌سازی در mapper

:core:network/data
04

Repository

Resource را باز می‌کند؛ domain model یا throw

:core:network/repositories
05

module Hilt

Api را از Retrofit مشترک فراهم می‌کند

:core:network/di
06

Hookها

useXxx() با useQuery / useMutation؛ Repository را به HookProvider اضافه کنید

:app/hooks
07

screen

Composableهای کوچک با QueryContent؛ route در Routes.kt

:app/ui/<area>
08

test

mapper، validation و domain logic

src/test
مرجع سریع QueryOptions · QueryClient

Cheat sheet

optiondefaultیعنی
staleTime۵ دقیقهتا این مدت از cache، بدون request
cacheTime۱۰ دقیقهعمر entry بعد از بسته شدن آخرین screen
enabledtruefalse تا وقتی id یا dependency آماده نیست
refetchInterval0polling وقتی screen STARTED است
maxAttempts3کل attemptها برای transient error
retryDelayMs1000linear backoff: ۱×، ۲×، …
QueryOptions.freshFor(60_000)   // feed-style
QueryOptions.OfflineForever     // only via invalidate

queryClient.invalidate(key)
queryClient.invalidateMatching("articles\\..*")
queryClient.invalidateAll(listOf(a, b))
queryClient.setData(key, value)     // optimistic
queryClient.prefetch(key) { repo.load() }
queryClient.clear()                 // on logout
queryClient.metricsSnapshot()       // hit rate, errors
test core/query · QueryTest.kt

Engine بدون Compose test می‌شود

۱۲ engine test

  • serves cache within staleTime
  • de-duplicates concurrent reads
  • runs fetcher once across threads
  • persistent tier survives failed fetch
  • retries transient, not 4xx
  • skips a tick on a running fetch
  • evicts unobserved slots
  • invalidate forces a refetch
@Test
fun `concurrent reads are de-duplicated to a single fetch`() =
    runTest(UnconfinedTestDispatcher()) {
        val gate = CompletableDeferred<Unit>()
        var fetches = 0
        val entry = QueryEntry(
            key = "headlines",
            fetcher = { gate.await(); "value-${++fetches}" },
            staleTime = 0L,
            cacheTime = Long.MAX_VALUE,
            scope = backgroundScope,
        )
        entry.observe()
        entry.fetch()
        entry.fetch()
        assertEquals(0, fetches)   // still gated

        gate.complete(Unit)
        assertEquals(1, fetches)
    }
تمرین Hands-on · ComposeCleanArch

تمرین: screen «منابع خبری»

هدف: لیست منابع NewsAPI، و با کلیک، تیترهای همان منبع

  1. مدل Source در :core:model
  2. SourceApi برای GET top-headlines/sources + SourceKeys
  3. DTO و mapper — و یک test برای mapper
  4. SourceRepository و module Hilt؛ اضافه به HookProvider
  5. useSources() با QueryOptions.freshFor(1h)
  6. SourcesScreen با QueryContent و route جدید
  7. کلیک روی منبع → useHeadlines(source = id)

امتیازی

  • refetchInterval بگذارید و ببینید در background متوقف می‌شود
  • با prefetch تیترها را قبل از navigation گرم کنید
  • با metricsSnapshot() نرخ cache hit را log کنید

چک‌لیست بازبینی

screen ViewModel ندارد؟ Loading/Error دستی نیست؟ key در SourceKeys است؟ Resource از Repository بیرون نیامده؟

جمع‌بندی Recap

همان جمله‌ی اول، حالا با معنی

server data مال ما نیست؛ یک کپی است. پس یک جعبه‌ی مشترک برایش داریم، و screen فقط می‌گوید چه data‌ای می‌خواهد.

01

وب

همین مشکل را داشت و React Query حلش کرد

02

ما

ViewModelها: کپی‌های جدا از یک data

03

جعبه

QueryEntry با key، در QueryClient

04

قانون‌ها

staleTime، observers، یک request، retry، invalidate

05

از فردا

recipe هشت‌قدمی؛ ViewModel فقط برای screen state

پرسش و پاسخ Q&A

ممنون 🙏

سؤال‌ها، نقدها، و اینکه از کدام ViewModel شروع کنیم.

base app

github.com/lumoradevlab/ComposeCleanArch

README.md · ARCHITECTURE.md

contact

parisaghader.org

Parisa Hazhir Ghader

1 / 1

کلیدهای میان‌بر

→ ↓ Space PgDnاسلاید بعد
← ↑ PgUpاسلاید قبل
Home / Endاول / آخر
Nپنجره‌ی یادداشت گوینده
Tتم روشن / تیره
+ − 0بزرگ / کوچک / عادی کردن متن
Fتمام‌صفحه
? Escاین راهنما