screenهایی بدون ViewModel — cache برای server data به سبک React Query، دستنویس برای Compose
React Query: چه مشکلی را حل کرد و چرا به Compose آمد
چرا ViewModel برای server data زیادی است
یک جعبهی مشترک، key، و سه ابزار: useQuery، useMutation، QueryContent
قانونهای جعبه: staleTime، observers، fetch، invalidate، retry، polling — با simulator
آنچه یک پیادهسازی ساده اشتباه میکند
recipe ساخت feature، rules و یک تمرین عملی
server data مال ما نیست؛ یک کپی است. پس به جای اینکه هر screen کپی خودش را در یک ViewModel نگه دارد، یک cache مشترک داریم و screen فقط میگوید چه dataای میخواهد.
یک جعبه برای یک تکه server data، مشترک بین همهی screenها.
مثل "wallet.balance.USDT"؛ screen با آن جعبهاش را پیدا میکند.
یک map که همهی جعبهها را با اسمشان نگه میدارد.
هر چیز دیگری امروز — staleTime، retry، invalidate — فقط یک قانون دربارهی همین جعبه است.
قبل از هر خط کد اندرویدی: این ایده در دنیای وب چه مشکلی را حل کرد، و چرا به Compose میآید.
// 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.
// 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 مرکزی است.
component دیگر server data را «مدیریت» نمیکند؛ فقط آن را «request» میکند.
| React | Compose |
|---|---|
| useMemo / useRef | remember |
| useEffect | LaunchedEffect / DisposableEffect |
| Context | CompositionLocal |
| custom hook | @Composable fun useX() |
ViewModel + StateFlow همان Redux + thunk است: هر screen cache خودش را میسازد.
Store5 cache را در Repository میگذارد و از screen روی صفحه خبر ندارد؛ Apollo فقط برای GraphQL است.
حدود ۸۸۰ خط Kotlin با Coroutines و StateFlow، بدون dependency بیرونی، با ۱۲ test.
بیشتر screenهای ما فقط dataای را از سرور نشان میدهند. اما برای هر کدام یک ViewModel، یک UiState و یک load() مینویسیم.
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)
}
}
@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 ندارد.
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()
}
}
مشکل فقط تعداد خط نیست: هر ViewModel یک کپی جدا از یک data دارد، و ما مسئولیم این کپیها را دستی هماهنگ نگه داریم.
| ویژگی | Client state | Server state |
|---|---|---|
| مالک | خود screen | سرور — ما فقط یک کپی داریم |
| stale شدن | stale نمیشود | هر لحظه ممکن است stale شود |
| اشتراک | معمولاً یک screen | چند screen همان data را میخواهند |
| چه لازم دارد | نگهداری ساده | cache، refetch، de-duplication، retry، invalidate |
| مثال | متن فرم، تب انتخابشده، باز بودن bottom sheet | موجودی کیف پول، سفارشهای باز، قیمتها |
ViewModel برای screen state ابزار خوبی است. اما برای server data، هر screen دوباره همان cache ناقص را از نو مینویسد.
یک cache layer مشترک برای همهی server data؛ screen فقط میگوید «چه dataای» و «success state چه شکلی است».
یک جعبه، پس عدد هیچوقت بین screenها فرق نمیکند.
یک request جعبه را پر میکند و همه نتیجه را میبینند.
بعد از خرید، همین یک جعبه refresh میشود و همهی screenها با هم عوض میشوند.
@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"
}
همان جعبه را برمیگرداند، پس screen دوم جعبهی screen اول را میگیرد. نیست؟ یک جعبهی جدید میسازد.
| React Query | ComposeCleanArch | تفاوت و دلیل |
|---|---|---|
| QueryClient + QueryClientProvider | QueryClient (@Singleton) + LocalQueryClient | DI مالک 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 error | 4xx تکرار نمیشود |
| refetchOnWindowFocus | polling فقط در STARTED | lifecycle اندروید جای focus پنجره |
| isLoading / Suspense | QueryState sealed + QueryContent | compiler case جاافتاده را میگیرد |
| useMutation + invalidateQueries | useMutation + invalidateKeys | write single-flight، بدون retry default |
یک key و یک fetch function. cache، freshness، de-duplication و retry را خودش انجام میدهد.
ثبت، حذف، ویرایش. بعد از موفقیت، keyهای مربوط را invalidate میکند تا screenها refetch شوند.
Loading و Error را یکجا render میکند. screen فقط success state را توصیف میکند.
یک @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 بیرونی.
فقط success state را میسازد
key + repository از طریق HookProvider
cache، staleTime، de-duplication، retry، polling، evict
Resource را باز میکند؛ domain model برمیگرداند یا throw میکند
هر responseی — ۲xx، error با هر شکلی، JSON خراب، connection drop — بدون throw به Resource تبدیل میشود. safeApiCall لازم نیست.
همهی UI در :app، همهی data در :core:network. plugin convention برای روزی که یک feature واقعاً بزرگ شود آماده است.
بدون Android، Retrofit یا Gson. mapperهای DTO→model در :core:network هستند، چون به DTO اشاره میکنند — وگرنه circular dependency میشد.
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 با هم؟
دقیقاً یکی از سه حالت. when کامل است و compiler case جاافتاده را یادآوری میکند.
stale-while-revalidate: data روی صفحه میماند و spinner چشمک نمیزند، تا response جدید برسد.
هر key یک QueryEntry دارد. بیایید با simulator ببینیم در طول زمان با آن چه اتفاقی میافتد.
| داخل جعبه | کار جعبه |
|---|---|
| خالی | به سرور برو |
| data دارد و fresh است | همان را بده؛ request نزن |
| data دارد ولی stale است | به سرور برو |
private fun shouldFetch(): Boolean =
current == null || // empty box
(now() - lastFetch) > staleTime // or old data
۲ دقیقه پیش گرفته شده: fresh، بدون request.
۷ دقیقه پیش: stale، دوباره fetch.
stale شدن بهتنهایی request نمیزند. جعبه فقط علامت میخورد؛ دفعهی بعد که screenی بازش کند refetch میشود. data زنده = refetchInterval.
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
}
staleTime (۵ دقیقه): تا کی data fresh است.
cacheTime (۱۰ دقیقه): جعبهای که کسی نگاهش نمیکند تا کی نگه داشته میشود.
جعبه خالی: Loading. data دارد: Success(old, refreshing)؛ spinner نمیآید
همان 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
}
}
}
useWalletBalance("USDT") → key "wallet.balance.USDT.spot"
QueryEntry برای هر key فقط یک in-flight request نگه میدارد؛ بقیه به همان وصل میشوند و همه یک StateFlow مشترک را میبینند.
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)
error به AppError تبدیل میشود؛ retry فقط برای transient error
keyهایی که یک screen باز دارند همین حالا refetch میشوند
دفعهی بعد که screenشان باز شود، fresh data میگیرند
روی یک 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
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,
),
)
| نوع data | interval پیشنهادی |
|---|---|
| سفارشهای باز | 5s |
| تاریخچهی معاملات | 10s |
| خلاصهی داشبورد | 30s |
| موجودی کیف پول | 15s |
| اعلانها و پیامها | 60s |
هر چه data سریعتر عوض شود، interval کوتاهتر؛ برای هر ثانیه، WebSocket.
@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()
}
برای screen فرقی ندارد data از REST با cache میآید یا از WebSocket: یک function useX() صدا میزند و مقدار میگیرد.
connection در background میخوابد و با برگشت کاربر دوباره وصل میشود؛ هیچ screenای لازم نیست این را بداند.
val profile = useCachedQuery(
key = ProfileKeys.PROFILE,
fetcher = { api.getProfile() }, // network -> ProfileEntity
reader = { dao.observe() }, // Room -> Flow<ProfileEntity?>
writer = { dao.upsert(it) },
) // default: QueryOptions.OfflineForever
بیشتر keyها in-memory هستند (useQuery). Room layer فقط برای dataای که واقعاً باید offline بماند.
useQuery ساده را میشود یک عصره نوشت. کار میکند — تا وقتی network کند شود، کاربر سریع بزند، یا اپ ساعتها باز بماند.
| موضوع | نسخهی ساده | ComposeCleanArch |
|---|---|---|
| state | Resource با سه فیلد جدا | QueryState sealed: Loading / Success / Failure |
| Loading و Error | هر screen خودش | QueryContent یکجا |
| retry در read | یا همهی errorها، یا هیچکدام | ۳ attempt، فقط transient error، با delay ۱ و ۲ ثانیه |
| retry در write | روی هر error — حتی 4xx | فقط transient error؛ default بدون retry |
| دو بار زدن دکمه | دو request | single-flight: دومی نادیده گرفته میشود |
| late response | data جدیدتر را میپوشاند | 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 |
سریع دو یا سه بار بزنید
in-flight: ۰در نسخهی ساده اگر UI دکمه را بهموقع غیرفعال نکند، هر ضربه یک coroutine جدا میسازد. ComposeCleanArch این را در خود useMutation تضمین میکند، نه در هر screen.
Rules، recipe ساخت یک feature، و یک تمرین روی ComposeCleanArch.
state holder خودش fetch نمیکند. screen state و validation را نگه میدارد و همان hookها و mutationها را صدا میزند — نه یک راه دوم برای گرفتن data.
AppResult و AppError. Resource فقط داخل :core:network؛ از Repository بیرون نمیآید.
Composable بالای ~۲۵۰ خط به componentهای کوچکتر تقسیم میشود.
نه یک interface با ۲۰۰ endpoint. keyها در object XxxKeys کنار همان Api.
در :core:model هیچ Android، Retrofit، Compose یا Gson.
@IoDispatcher، نه Dispatchers.IO — تا test بتواند TestDispatcher بدهد.
پشت یک domain type به اسم Money. هرگز Double یا Float برای موجودی و کارمزد.
data class خالص؛ منطق قابلمحاسبه بهصورت property
:core:modelXxxApi با خروجی Resource<…> و object XxxKeys
:core:network/apisهمهچیز روی سیم nullable؛ نرمالسازی در mapper
:core:network/dataResource را باز میکند؛ domain model یا throw
:core:network/repositoriesApi را از Retrofit مشترک فراهم میکند
:core:network/diuseXxx() با useQuery / useMutation؛ Repository را به HookProvider اضافه کنید
:app/hooksComposableهای کوچک با QueryContent؛ route در Routes.kt
:app/ui/<area>mapper، validation و domain logic
src/test| option | default | یعنی |
|---|---|---|
| staleTime | ۵ دقیقه | تا این مدت از cache، بدون request |
| cacheTime | ۱۰ دقیقه | عمر entry بعد از بسته شدن آخرین screen |
| enabled | true | false تا وقتی id یا dependency آماده نیست |
| refetchInterval | 0 | polling وقتی screen STARTED است |
| maxAttempts | 3 | کل attemptها برای transient error |
| retryDelayMs | 1000 | linear 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
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)
}
screen ViewModel ندارد؟ Loading/Error دستی نیست؟ key در SourceKeys است؟ Resource از Repository بیرون نیامده؟
server data مال ما نیست؛ یک کپی است. پس یک جعبهی مشترک برایش داریم، و screen فقط میگوید چه dataای میخواهد.
همین مشکل را داشت و React Query حلش کرد
ViewModelها: کپیهای جدا از یک data
QueryEntry با key، در QueryClient
staleTime، observers، یک request، retry، invalidate
recipe هشتقدمی؛ ViewModel فقط برای screen state
سؤالها، نقدها، و اینکه از کدام ViewModel شروع کنیم.
github.com/lumoradevlab/ComposeCleanArch
README.md · ARCHITECTURE.md
parisaghader.org
Parisa Hazhir Ghader
| → ↓ Space PgDn | اسلاید بعد |
| ← ↑ PgUp | اسلاید قبل |
| Home / End | اول / آخر |
| N | پنجرهی یادداشت گوینده |
| T | تم روشن / تیره |
| + − 0 | بزرگ / کوچک / عادی کردن متن |
| F | تمامصفحه |
| ? Esc | این راهنما |