Composition over Inheritance trong Android: Vì sao nên hạn chế BaseActivity và BaseViewModel?
Vì sao codebase Android hiện đại hạn chế Inheritance? Từ BaseActivity đến Composition
Khi mới học lập trình hướng đối tượng, chúng ta thường được giới thiệu inheritance – kế thừa như một cách tái sử dụng code:
- Viết logic chung trong class cha.
- Cho nhiều class con kế thừa.
- Override những phần khác nhau.
- Tránh copy-paste code.
Về lý thuyết, cách làm này rất hợp lý. Trong Android, chúng ta cũng dễ dàng bắt gặp những class như:
BaseActivity
BaseFragment
BaseViewModel
BaseRepository
BaseUseCase
Ban đầu, những class này giúp code trông gọn hơn. Tuy nhiên, khi dự án phát triển, chúng thường trở thành nơi chứa đủ loại logic không liên quan: loading, analytics, navigation, xử lý lỗi, kiểm tra mạng, authentication…
Video “Why the Best Codebases Barely Use Inheritance Anymore?” giải thích vì sao nhiều codebase lớn hiện nay có xu hướng ưu tiên composition over inheritance — ghép các hành vi nhỏ lại với nhau thay vì xây dựng một cây kế thừa sâu.
Trong bài viết này, chúng ta sẽ phân tích:
- Inheritance bắt đầu gây vấn đề như thế nào.
- Composition giải quyết vấn đề đó ra sao.
- Cách áp dụng composition trong Android với Kotlin, ViewModel, Dependency Injection và Jetpack Compose.
- Khi nào inheritance vẫn là lựa chọn hợp lý.
1. Vấn đề không nằm ở việc tái sử dụng code
Cả inheritance và composition đều cố gắng giải quyết cùng một bài toán:
Viết logic một lần và sử dụng lại ở nhiều nơi mà không phải copy-paste.
Điểm khác biệt nằm ở cách chúng tổ chức mối quan hệ giữa các thành phần.
Inheritance
Inheritance tạo ra mối quan hệ:
Class con là một loại của class cha.
Ví dụ:
abstract class Order {
abstract fun ship()
abstract fun track()
}
Sau đó chúng ta tạo các loại đơn hàng:
class StandardOrder : Order() {
override fun ship() {
println("Ship by ground")
}
override fun track() {
println("Track with courier API")
}
}
class ExpressOrder : Order() {
override fun ship() {
println("Ship by air")
}
override fun track() {
println("Track express package")
}
}
Ở thời điểm này, thiết kế có vẻ hoàn toàn hợp lý. Tất cả đơn hàng đều cần giao và theo dõi trạng thái vận chuyển.
Nhưng một yêu cầu mới xuất hiện:
class DigitalOrder : Order()
Digital order có thể là:
- E-book.
- License phần mềm.
- Khóa học online.
- File tải xuống.
Loại đơn hàng này không có hoạt động vận chuyển vật lý. Tuy nhiên, vì kế thừa Order, nó vẫn bị ép phải implement ship() và track().
Chúng ta có thể viết:
class DigitalOrder : Order() {
override fun ship() {
sendDownloadLink()
}
override fun track() {
throw UnsupportedOperationException(
"Digital order cannot be tracked"
)
}
private fun sendDownloadLink() {
// Send license or download URL
}
}
Code có thể compile, nhưng thiết kế đã bắt đầu có vấn đề.
Một object được khai báo là Order nhưng lại không thực hiện được đầy đủ hành vi mà Order cam kết.
Video sử dụng chính tình huống này để chỉ ra điểm yếu quan trọng của inheritance: class cha phải dự đoán được những hành vi mà tất cả class con trong tương lai đều có. Khi dự đoán đó sai, toàn bộ hierarchy bắt đầu trở nên khó thay đổi.
2. Những vấn đề thường gặp khi lạm dụng inheritance
2.1. Class con bị ép nhận những hành vi không cần thiết
Giả sử trong Android chúng ta có:
abstract class BaseActivity : AppCompatActivity() {
abstract fun setupToolbar()
abstract fun observeNetwork()
abstract fun trackScreen()
abstract fun showLoading()
abstract fun hideLoading()
}
Một màn hình rất đơn giản vẫn phải override toàn bộ method:
class AboutActivity : BaseActivity() {
override fun setupToolbar() {
// About screen does not have a toolbar
}
override fun observeNetwork() {
// This screen does not use network
}
override fun trackScreen() {
// Not required
}
override fun showLoading() {
// Not required
}
override fun hideLoading() {
// Not required
}
}
Đây là một dấu hiệu rõ ràng cho thấy abstraction hiện tại không còn phù hợp.
Class con đang phải:
- Implement method rỗng.
- Throw exception.
- Thêm comment “do not call”.
- Chấp nhận những dependency không cần thiết.
2.2. Thay đổi class cha ảnh hưởng đến tất cả class con
Một BaseActivity ban đầu có thể chỉ chứa vài dòng code:
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
configureStatusBar()
}
}
Sau một thời gian, team tiếp tục thêm:
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
configureStatusBar()
checkNetwork()
observeAuthentication()
registerAnalytics()
registerEventBus()
setupGlobalLoading()
}
}
Lúc này, mọi Activity đều tự động chạy những logic trên, kể cả khi màn hình không cần chúng.
Chỉ một thay đổi trong BaseActivity cũng có thể tạo bug trên hàng chục màn hình. Đáng ngại hơn, hành vi đó thường bị ẩn trong class cha nên người đọc CheckoutActivity không biết màn hình đang đăng ký những observer nào.
Đây thường được gọi là vấn đề fragile base class: class cha càng chứa nhiều logic thì class con càng phụ thuộc vào implementation nội bộ của nó.
2.3. Không thể kết hợp hành vi một cách linh hoạt
Giả sử ứng dụng có ba loại hành vi:
- Analytics.
- Authentication.
- Ads.
Nếu dùng inheritance, chúng ta có thể bắt đầu với:
abstract class AnalyticsActivity : AppCompatActivity()
abstract class AuthenticatedActivity : AppCompatActivity()
abstract class AdsActivity : AppCompatActivity()
Nhưng nếu một màn hình vừa cần authentication vừa cần analytics thì sao?
Kotlin và Java không hỗ trợ một class kế thừa implementation từ nhiều class cha:
// Không hợp lệ
class ProfileActivity :
AnalyticsActivity(),
AuthenticatedActivity()
Team có thể tiếp tục tạo:
abstract class AuthenticatedAnalyticsActivity : AppCompatActivity()
Sau đó lại xuất hiện màn hình cần cả analytics, authentication và ads:
abstract class AuthenticatedAnalyticsAdsActivity : AppCompatActivity()
Số lượng class trung gian sẽ tăng theo số tổ hợp hành vi.
Inheritance tổ chức code theo một cây cố định, trong khi requirement thực tế thường cần kết hợp hành vi theo nhiều cách khác nhau.
3. Composition là gì?
Thay vì nói:
Object này kế thừa toàn bộ hành vi từ object khác.
Composition nói rằng:
Object này sử dụng một số object nhỏ hơn để thực hiện công việc.
Quay lại ví dụ đơn hàng, chúng ta có thể tách phương thức giao hàng thành một contract riêng:
interface DeliveryMethod {
suspend fun deliver(order: Order)
}
Các implementation:
class GroundDelivery : DeliveryMethod {
override suspend fun deliver(order: Order) {
println("Ship order ${order.id} by ground")
}
}
class AirDelivery : DeliveryMethod {
override suspend fun deliver(order: Order) {
println("Ship order ${order.id} by air")
}
}
class DigitalDelivery : DeliveryMethod {
override suspend fun deliver(order: Order) {
println("Send download link for order ${order.id}")
}
}
Order chỉ cần chứa dữ liệu của đơn hàng:
data class Order(
val id: String,
val items: List<OrderItem>,
val total: Long
)
Service sử dụng DeliveryMethod:
class CheckoutService(
private val deliveryMethod: DeliveryMethod
) {
suspend fun complete(order: Order) {
// Save order, process payment...
deliveryMethod.deliver(order)
}
}
Bây giờ, chúng ta có thể thay đổi cách giao hàng mà không sửa Order hoặc CheckoutService:
val physicalCheckout = CheckoutService(
deliveryMethod = GroundDelivery()
)
val digitalCheckout = CheckoutService(
deliveryMethod = DigitalDelivery()
)
Điểm quan trọng nhất là:
Order không cần biết nó sẽ được giao như thế nào.
CheckoutService không cần biết implementation cụ thể.
DeliveryMethod có thể được thay thế độc lập.
Đây cũng chính là nền tảng của Dependency Injection:
Một class nhận dependency từ bên ngoài thay vì tự tạo dependency bên trong.
Android Architecture Recommendations hiện khuyến nghị sử dụng dependency injection, ưu tiên constructor injection khi có thể.
4. Ứng dụng Composition trong Android
4.1. Thay BaseViewModel bằng các dependency nhỏ
Một cách triển khai thường gặp là đặt toàn bộ logic chung vào BaseViewModel:
abstract class BaseViewModel : ViewModel() {
val isLoading = MutableStateFlow(false)
val error = MutableSharedFlow<Throwable>()
fun launchRequest(
block: suspend () -> Unit
) {
viewModelScope.launch {
isLoading.value = true
runCatching {
block()
}.onFailure {
error.emit(it)
}
isLoading.value = false
}
}
fun trackEvent(name: String) {
// Analytics logic
}
}
Sau đó mọi ViewModel đều kế thừa:
class CheckoutViewModel : BaseViewModel() {
fun checkout() {
launchRequest {
trackEvent("checkout")
// Place order
}
}
}
Ban đầu rất tiện, nhưng sau đó BaseViewModel có thể tiếp tục chứa:
- Loading.
- Error handling.
- Analytics.
- Navigation event.
- Authentication.
- Coroutine dispatcher.
- Resource provider.
- Network checking.
Mọi ViewModel sẽ bị gắn với toàn bộ những trách nhiệm này.
Sử dụng composition
Tách từng trách nhiệm thành một component:
interface AnalyticsTracker {
fun track(event: String)
}
interface ErrorMapper {
fun map(throwable: Throwable): String
}
class PlaceOrderUseCase @Inject constructor(
private val repository: OrderRepository
) {
suspend operator fun invoke(): Result<Order> {
return repository.placeOrder()
}
}
ViewModel nhận đúng những dependency nó cần:
@HiltViewModel
class CheckoutViewModel @Inject constructor(
private val placeOrder: PlaceOrderUseCase,
private val analyticsTracker: AnalyticsTracker,
private val errorMapper: ErrorMapper
) : ViewModel() {
private val _uiState =
MutableStateFlow<CheckoutUiState>(CheckoutUiState.Idle)
val uiState = _uiState.asStateFlow()
fun checkout() {
viewModelScope.launch {
_uiState.value = CheckoutUiState.Loading
analyticsTracker.track("checkout_started")
placeOrder()
.onSuccess { order ->
_uiState.value =
CheckoutUiState.Success(order)
}
.onFailure { throwable ->
_uiState.value = CheckoutUiState.Error(
message = errorMapper.map(throwable)
)
}
}
}
}
ViewModel này không cần biết:
- Analytics được gửi lên Firebase hay hệ thống nội bộ.
- Error được map theo ngôn ngữ nào.
- Repository đọc dữ liệu từ API hay local database.
Mỗi dependency có thể thay thế độc lập khi test:
val viewModel = CheckoutViewModel(
placeOrder = fakePlaceOrderUseCase,
analyticsTracker = fakeAnalyticsTracker,
errorMapper = fakeErrorMapper
)
Hilt là thư viện dependency injection được Jetpack khuyến nghị cho Android và giúp quản lý dependency cũng như lifecycle của chúng.
4.2. Thay BaseActivity bằng các controller hoặc delegate
Thay vì đặt analytics vào BaseActivity:
abstract class BaseActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
trackScreen()
}
abstract fun trackScreen()
}
Chúng ta có thể tạo một component độc lập:
interface ScreenTracker {
fun track(screenName: String)
}
Activity sử dụng dependency đó:
@AndroidEntryPoint
class CheckoutActivity : AppCompatActivity() {
@Inject
lateinit var screenTracker: ScreenTracker
override fun onStart() {
super.onStart()
screenTracker.track("checkout")
}
}
Hoặc đóng gói việc xử lý lifecycle:
class ScreenAnalyticsObserver(
private val screenName: String,
private val tracker: ScreenTracker
) : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
tracker.track(screenName)
}
}
Activity chỉ cần đăng ký component:
@AndroidEntryPoint
class CheckoutActivity : AppCompatActivity() {
@Inject
lateinit var screenTracker: ScreenTracker
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(
ScreenAnalyticsObserver(
screenName = "checkout",
tracker = screenTracker
)
)
}
}
Một Activity không cần analytics thì không đăng ký observer.
Một Activity cần thêm authentication có thể ghép thêm một observer khác:
lifecycle.addObserver(authenticationObserver)
lifecycle.addObserver(screenAnalyticsObserver)
Chúng ta đang kết hợp hành vi theo nhu cầu thay vì tạo thêm một tầng inheritance.
4.3. Sử dụng Delegation của Kotlin
Kotlin hỗ trợ delegation trực tiếp bằng từ khóa by. Tài liệu Kotlin mô tả delegation là một giải pháp thay thế hiệu quả cho implementation inheritance và giúp giảm boilerplate.
Giả sử chúng ta có repository:
interface UserRepository {
suspend fun getUser(): User
suspend fun updateUser(user: User)
}
Implementation thật:
class DefaultUserRepository(
private val api: UserApi
) : UserRepository {
override suspend fun getUser(): User {
return api.getUser()
}
override suspend fun updateUser(user: User) {
api.updateUser(user)
}
}
Bây giờ cần thêm logging nhưng không muốn sửa implementation gốc:
class LoggingUserRepository(
private val delegate: UserRepository,
private val logger: Logger
) : UserRepository by delegate {
override suspend fun getUser(): User {
logger.log("Getting user")
return delegate.getUser()
}
}
LoggingUserRepository tự động chuyển tiếp các method chưa override sang delegate.
Chúng ta có thể tiếp tục bọc repository:
val repository: UserRepository =
LoggingUserRepository(
delegate = DefaultUserRepository(api),
logger = logger
)
Cách này phù hợp với các bài toán:
- Logging.
- Caching.
- Retry.
- Analytics.
- Authorization.
- Feature flag.
- Performance monitoring.
Mỗi hành vi là một lớp bọc độc lập thay vì một class cha khổng lồ.
5. Jetpack Compose là một ví dụ rõ ràng về tư duy composition
Jetpack Compose được xây dựng xung quanh việc ghép các composable function nhỏ để tạo thành UI lớn hơn.
Một composable nhận dữ liệu và tạo ra UI bằng cách gọi những composable khác. Khi người dùng tương tác, UI gửi event ngược về app logic thay vì tự giữ toàn bộ business logic bên trong.
Ví dụ:
@Composable
fun CheckoutScreen(
state: CheckoutUiState,
onCheckout: () -> Unit,
onRetry: () -> Unit
) {
Scaffold(
topBar = {
CheckoutTopBar()
}
) { padding ->
CheckoutContent(
modifier = Modifier.padding(padding),
state = state,
onCheckout = onCheckout,
onRetry = onRetry
)
}
}
CheckoutContent tiếp tục được compose từ các thành phần nhỏ:
@Composable
private fun CheckoutContent(
state: CheckoutUiState,
onCheckout: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
OrderSummary()
ShippingAddress()
PaymentMethod()
when (state) {
CheckoutUiState.Idle -> {
CheckoutButton(onClick = onCheckout)
}
CheckoutUiState.Loading -> {
CircularProgressIndicator()
}
is CheckoutUiState.Error -> {
ErrorContent(
message = state.message,
onRetry = onRetry
)
}
is CheckoutUiState.Success -> {
SuccessContent(order = state.order)
}
}
}
}
Không cần:
BaseCheckoutView
BaseLoadingCheckoutView
BaseErrorCheckoutView
AuthenticatedCheckoutView
Thay vào đó, màn hình được tạo ra bằng cách ghép:
Scaffold
+ TopBar
+ OrderSummary
+ ShippingAddress
+ PaymentMethod
+ Loading / Error / Success
Đây không hoàn toàn giống object composition trong OOP, nhưng chúng cùng chia sẻ một tư duy:
Xây dựng hệ thống lớn từ những thành phần nhỏ, độc lập và có thể kết hợp.
6. Composition không có nghĩa là loại bỏ interface
Một nhầm lẫn phổ biến là:
Không dùng inheritance thì cũng không nên dùng interface.
Thực tế, vấn đề chủ yếu nằm ở implementation inheritance, tức là class con nhận cả state và implementation từ class cha.
Interface vẫn rất hữu ích để định nghĩa contract:
interface PaymentProcessor {
suspend fun pay(amount: Long): PaymentResult
}
Các implementation:
class MomoPaymentProcessor : PaymentProcessor
class ZaloPayPaymentProcessor : PaymentProcessor
class GooglePayPaymentProcessor : PaymentProcessor
Service phụ thuộc vào abstraction:
class PaymentService(
private val processor: PaymentProcessor
)
Đây là sự kết hợp giữa:
- Interface để định nghĩa contract.
- Composition để ghép object.
- Dependency Injection để cung cấp implementation.
7. Khi nào inheritance vẫn phù hợp?
Inheritance không phải lúc nào cũng xấu.
Trong Android, chúng ta vẫn thường xuyên phải kế thừa:
class MainActivity : ComponentActivity()
class HomeFragment : Fragment()
class HomeViewModel : ViewModel()
class CustomView : View()
Đó là vì Android framework cung cấp các extension point thông qua inheritance.
Inheritance có thể hợp lý khi:
Framework yêu cầu
Ví dụ:
Activity.Fragment.ViewModel.View.Application.- Custom exception.
Quan hệ “is-a” thực sự ổn định
Class con thực sự là một phiên bản của class cha và có thể thực hiện đầy đủ contract của class cha.
Hierarchy nông
Thông thường chỉ có một tầng kế thừa framework:
ComponentActivity
└── MainActivity
Thay vì:
ComponentActivity
└── BaseActivity
└── BaseAuthenticatedActivity
└── BaseAnalyticsActivity
└── CheckoutActivity
Class cha được thiết kế rõ ràng để mở rộng
Class cha nên:
- Có contract nhỏ.
- Không expose state nội bộ không cần thiết.
- Có extension point rõ ràng.
- Không ép class con implement hành vi không liên quan.
- Không chứa quá nhiều trách nhiệm.
8. Checklist khi thiết kế class trong Android
Trước khi tạo một BaseSomething, có thể tự hỏi:
Class con có thực sự là một loại của class cha không?
Nếu câu trả lời chỉ là:
Vì chúng dùng chung một đoạn code.
Thì composition có thể phù hợp hơn.
Tất cả class con có cần toàn bộ hành vi của class cha không?
Nếu một số class phải:
override fun something() {
// Do nothing
}
Hoặc:
throw UnsupportedOperationException()
Abstraction có khả năng đang sai.
Hành vi này có thể tách thành dependency không?
Ví dụ:
AnalyticsTracker
ErrorMapper
Navigator
AuthenticationManager
LoadingController
NetworkMonitor
Logger
PaymentProcessor
Hành vi có cần thay đổi độc lập không?
Nếu cần sử dụng implementation khác trong:
- Unit test.
- Debug build.
- Production build.
- Từng feature.
- Từng loại tài khoản.
Hãy cân nhắc interface kết hợp với dependency injection.
Có đang tạo cây kế thừa chỉ để chia sẻ boilerplate không?
Đôi khi một chút boilerplate rõ ràng vẫn tốt hơn một abstraction phức tạp và khó thay đổi.
9. Kết luận
Inheritance giúp tái sử dụng code nhanh ở giai đoạn đầu, nhưng nó đồng thời tạo ra một cam kết rất mạnh:
Class con phải sống bên trong cấu trúc và các giả định do class cha định nghĩa.
Khi requirement thay đổi, những giả định này có thể không còn đúng. Class con bắt đầu phải implement method rỗng, throw exception hoặc phụ thuộc vào những hành vi mà nó không cần.
Composition tiếp cận vấn đề theo hướng khác:
- Tách từng trách nhiệm thành component nhỏ.
- Định nghĩa contract bằng interface.
- Truyền dependency từ bên ngoài.
- Kết hợp hành vi tùy theo từng use case.
- Thay thế implementation mà không thay đổi object sử dụng nó.
Trong Android, tư duy này có thể được áp dụng thông qua:
- Constructor injection.
- Hilt hoặc manual dependency injection.
- Lifecycle observer.
- Repository và use case.
- Kotlin delegation.
- State hoisting và composable function trong Jetpack Compose.
Inheritance vẫn cần thiết, đặc biệt khi làm việc với Android framework. Tuy nhiên, chúng ta nên giữ hierarchy nông và chỉ kế thừa khi thực sự tồn tại một quan hệ is-a ổn định.
Một nguyên tắc thực tế có thể áp dụng là:
Kế thừa framework khi bắt buộc. Dùng interface để định nghĩa contract. Dùng composition để xây dựng hành vi của ứng dụng.
Khi chưa chắc nên chọn cách nào, hãy bắt đầu với composition. Nó thường tạo ra nhiều object nhỏ hơn, nhưng đổi lại codebase dễ thay đổi, dễ kiểm thử và ít phụ thuộc lẫn nhau hơn.
Tài liệu tham khảo
- Video: Why the Best Codebases Barely Use Inheritance Anymore?
- Android Developers: Recommendations for Android architecture.
- Android Developers: Dependency injection in Android.
- Kotlin Documentation: Delegation.
- Android Developers: Thinking in Compose.
Tags: Android, Kotlin, OOP, Inheritance, Composition, Dependency Injection, Hilt, Jetpack Compose, Clean Architecture
All rights reserved