Killing the BaseFragment: How to Build Truly Reusable Screens in Jetpack Compose
You spent years building a solid BaseFragment. Loading states, error handling, analytics hooks — al 2026-7-13 13:32:48 Author: hackernoon.com(查看原文) 阅读量:9 收藏

You spent years building a solid BaseFragment. Loading states, error handling, analytics hooks — all in one place. Then Compose showed up and said: "No more fragments." So where does all that shared logic go?


If you've been writing Android long enough, you know the BaseFragment pattern like an old friend. A common abstract class that every screen extended — handling loading overlays, snackbars, auth checks, toolbar setup, and whatever else your app needed consistently across screens.

Jetpack Compose is a paradigm shift, not just a UI toolkit upgrade. There are no fragments to subclass. No lifecycle hooks to override. Just functions calling functions. So the million-dollar question becomes: how do you share screen-level behavior across your entire app without inheritance?

The answer is: composition over inheritance — and Compose was designed for exactly this.


What Were We Actually Doing With BaseFragment?

Before we design the Compose equivalent, let's be honest about what BaseFragment was really responsible for:

  1. Common UI chrome — loading indicators, error states, empty states
  2. Navigation utilitiesnavigate() helpers, back stack management
  3. ViewModel wiring — observing UiState, handling one-time events
  4. Side effects — analytics, logging, permission checks
  5. Scaffold setup — toolbar, bottom nav, FAB behavior

In Compose, each of these has a better, more surgical home. Let's tackle them one by one.


Pattern 1: The BaseScreen Composable — Your New BaseFragment

The most direct translation is a wrapper composable that every screen routes through. Think of it as a higher-order composable that handles your cross-cutting concerns.

@Composable
fun BaseScreen(
    uiState: UiState,
    modifier: Modifier = Modifier,
    onRetry: (() -> Unit)? = null,
    content: @Composable () -> Unit
) {
    Box(modifier = modifier.fillMaxSize()) {
        when {
            uiState.isLoading -> FullScreenLoader()
            uiState.error != null -> ErrorScreen(
                message = uiState.error,
                onRetry = onRetry
            )
            else -> content()
        }
    }
}

Every screen uses it like this:

@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    BaseScreen(
        uiState = uiState,
        onRetry = viewModel::retry
    ) {
        HomeContent(uiState.data)
    }
}

Clean. No repetition. Change FullScreenLoader once and every screen in your app updates. This is your first and most impactful tool.


Pattern 2: ScaffoldScreen — Encapsulating Common App Chrome

Most apps have a consistent shell: top app bar, bottom navigation, maybe a FAB. Instead of re-specifying this on every screen, wrap Scaffold in a composable that accepts configuration.

data class ScreenConfig(
    val title: String = "",
    val showBackButton: Boolean = false,
    val actions: List<TopBarAction> = emptyList(),
    val fab: FabConfig? = null
)

@Composable
fun ScaffoldScreen(
    config: ScreenConfig,
    modifier: Modifier = Modifier,
    onBackClick: () -> Unit = {},
    content: @Composable (PaddingValues) -> Unit
) {
    Scaffold(
        modifier = modifier,
        topBar = {
            AppTopBar(
                title = config.title,
                showBackButton = config.showBackButton,
                actions = config.actions,
                onBackClick = onBackClick
            )
        },
        floatingActionButton = {
            config.fab?.let { FabButton(it) }
        }
    ) { padding ->
        content(padding)
    }
}

Usage becomes declarative — screens just describe what they need, not how to build it:

@Composable
fun ProfileScreen(onBack: () -> Unit) {
    ScaffoldScreen(
        config = ScreenConfig(
            title = "Profile",
            showBackButton = true,
            fab = FabConfig(icon = Icons.Default.Edit, onClick = { /* edit */ })
        ),
        onBackClick = onBack
    ) { padding ->
        ProfileContent(modifier = Modifier.padding(padding))
    }
}

Pattern 3: Composable + ViewModel Contract — Replacing the Abstract ViewModel

In the BaseFragment world, you often had a BaseViewModel with common streams:

abstract class BaseViewModel : ViewModel() {
    protected val _uiState = MutableStateFlow(UiState())
    val uiState = _uiState.asStateFlow()

    protected val _events = Channel<UiEvent>()
    val events = _events.receiveAsFlow()
}

This still makes sense in Compose. Define a shared interface and a concrete base, but make it leaner:

interface BaseViewModel {
    val uiState: StateFlow<UiState>
    val events: Flow<UiEvent>
    fun retry()
}

abstract class AppViewModel : ViewModel(), BaseViewModel {
    protected val _uiState = MutableStateFlow(UiState())
    override val uiState = _uiState.asStateFlow()

    private val _events = Channel<UiEvent>(Channel.BUFFERED)
    override val events = _events.receiveAsFlow()

    protected fun emitEvent(event: UiEvent) {
        viewModelScope.launch { _events.send(event) }
    }

    protected fun setLoading(loading: Boolean) {
        _uiState.update { it.copy(isLoading = loading) }
    }

    protected fun setError(message: String?) {
        _uiState.update { it.copy(error = message, isLoading = false) }
    }
}

Now every ViewModel in your app gets setLoading, setError, and emitEvent for free.


Pattern 4: CompositionLocal — The Dependency Injection of the UI Tree

CompositionLocal is Compose's answer to context-passing without prop drilling. It's perfect for injecting shared behavior that any composable in the tree might need: analytics, navigation, theming, feature flags.

// Define what's available app-wide
data class AppEnvironment(
    val analytics: AnalyticsTracker,
    val navigator: AppNavigator,
    val featureFlags: FeatureFlags
)

val LocalAppEnvironment = compositionLocalOf<AppEnvironment> {
    error("No AppEnvironment provided")
}

// Provide it once at the root
@Composable
fun AppRoot() {
    val environment = remember { AppEnvironment(/* inject deps */) }

    CompositionLocalProvider(LocalAppEnvironment provides environment) {
        AppNavGraph()
    }
}

// Consume it anywhere, without passing it through every function
@Composable
fun PurchaseButton(productId: String) {
    val analytics = LocalAppEnvironment.current.analytics

    Button(onClick = {
        analytics.track("purchase_clicked", mapOf("product_id" to productId))
    }) {
        Text("Buy Now")
    }
}

This is especially powerful for analytics — you never have to thread an analytics tracker through fifteen composables again.


Pattern 5: One-Time Events — Replacing observe Event Handling

One of the trickiest parts of BaseFragment was handling one-time events (show a toast, navigate, open a dialog). In Compose, LaunchedEffect + a Channel-backed flow is the canonical approach — and it belongs in a reusable handler.

@Composable
fun <VM : AppViewModel> HandleEvents(
    viewModel: VM,
    onNavigate: (destination: String) -> Unit = {},
    onShowMessage: (message: String) -> Unit = {}
) {
    val context = LocalContext.current

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is UiEvent.Navigate -> onNavigate(event.destination)
                is UiEvent.ShowToast -> Toast.makeText(
                    context, event.message, Toast.LENGTH_SHORT
                ).show()
                is UiEvent.ShowSnackbar -> onShowMessage(event.message)
            }
        }
    }
}

Use it alongside BaseScreen:

@Composable
fun CheckoutScreen(
    viewModel: CheckoutViewModel = hiltViewModel(),
    onNavigate: (String) -> Unit
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    HandleEvents(viewModel, onNavigate = onNavigate)

    BaseScreen(uiState = uiState, onRetry = viewModel::retry) {
        CheckoutContent(uiState.data, onConfirm = viewModel::confirm)
    }
}

Pattern 6: Reusable State Containers With rememberX

For UI logic that isn't ViewModel-level but is repeated across screens — think pull-to-refresh state, pagination scroll position, selection state — extract it into a remember-based state holder.

class PaginatedListState(
    initialItems: List<Any> = emptyList()
) {
    var items by mutableStateOf(initialItems)
        private set
    var isLoadingMore by mutableStateOf(false)
        private set
    var hasMore by mutableStateOf(true)
        private set

    fun onLoadMore(newItems: List<Any>) {
        items = items + newItems
        isLoadingMore = false
        hasMore = newItems.isNotEmpty()
    }

    fun setLoadingMore() { isLoadingMore = true }
}

@Composable
fun rememberPaginatedListState(
    initialItems: List<Any> = emptyList()
): PaginatedListState = remember { PaginatedListState(initialItems) }

Any screen with an infinite scroll list composes this in — no inheritance, no copy-pasting.


Putting It All Together: A Complete Screen

Here's what a fully-equipped screen looks like with all these patterns composing together:

@Composable
fun OrderHistoryScreen(
    viewModel: OrderHistoryViewModel = hiltViewModel(),
    onNavigate: (String) -> Unit,
    onBack: () -> Unit
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val listState = rememberPaginatedListState()

    // Handle one-time events
    HandleEvents(viewModel, onNavigate = onNavigate)

    // Consistent app chrome
    ScaffoldScreen(
        config = ScreenConfig(title = "Orders", showBackButton = true),
        onBackClick = onBack
    ) { padding ->

        // Consistent loading/error/content switching
        BaseScreen(
            uiState = uiState,
            onRetry = viewModel::retry,
            modifier = Modifier.padding(padding)
        ) {
            OrderList(
                orders = uiState.orders,
                listState = listState,
                onLoadMore = viewModel::loadNextPage
            )
        }
    }
}

This screen has zero boilerplate for loading states, error handling, scaffold chrome, event handling, or analytics. All of it is inherited through composition.


The Mental Model Shift

Here's a comparison to solidify the mapping:

BaseFragment Pattern

Compose Equivalent

abstract class BaseFragment

@Composable fun BaseScreen(...)

abstract class BaseViewModel

abstract class AppViewModel : ViewModel()

override fun setupToolbar()

ScaffoldScreen(config = ScreenConfig(...))

observe(viewModel.events)

HandleEvents(viewModel)

Dependency via constructor/DI

CompositionLocalProvider

Shared lifecycle observers

LaunchedEffect / DisposableEffect

Shared UI utilities

rememberX() state holders


What to Watch Out For

Don't overload BaseScreen. Keep it focused on loading/error/content. The moment you add "check auth state" or "request permission" to it, you've rebuilt BaseFragment's worst habits.

Prefer flat composition over deep nesting. If you find yourself with BaseScreen inside ScaffoldScreen inside HandleEvents inside PermissionWrapper, extract a StandardScreen composable that composes all of them in one call.

CompositionLocal is not a global state store. It's for stable, rarely-changing dependencies. Don't put mutable screen state in it.

Test each layer independently. The beauty of this approach is that BaseScreen is a pure composable you can test with composeTestRule without any ViewModel involved. Keep it that way.


Conclusion

The BaseFragment era gave us a blunt instrument for code sharing — inheritance. Jetpack Compose gives us something far more powerful: a composable function is both a unit of UI and a unit of logic, and it can be composed, layered, and parameterized without ever subclassing anything.

The patterns here — BaseScreen, ScaffoldScreen, AppViewModel, HandleEvents, CompositionLocal, and rememberX — aren't just replacements for BaseFragment. They're upgrades. Each piece is independently testable, independently reusable, and independently evolvable.

Stop looking for the Compose equivalent of BaseFragment. Start composing the pieces that were always hiding inside it.


If this was useful, follow me for more deep dives into real-world Compose architecture. Questions or battle-tested patterns of your own? Drop them in the comments.


文章来源: https://hackernoon.com/killing-the-basefragment-how-to-build-truly-reusable-screens-in-jetpack-compose?source=rss
如有侵权请联系:admin#unsafe.sh