Cấu trúc ứng dụng đề xuất của Android khuyến khích việc chia mã thành các lớp để hưởng lợi từ việc tách biệt các vấn đề, một nguyên tắc mà mỗi lớp trong hệ phân cấp chỉ có một trách nhiệm xác định. Điều này dẫn đến việc nhiều lớp nhỏ hơn cần được kết nối để thực hiện các phần phụ thuộc của nhau.
Các phần phụ thuộc giữa các lớp có thể được biểu thị dưới dạng biểu đồ, trong đó mỗi lớp được kết nối với các lớp mà nó phụ thuộc vào. Giá trị đại diện của tất cả các lớp và phần phụ thuộc của các lớp đó sẽ tạo nên biểu đồ ứng dụng. Trong hình 1, bạn có thể thấy bản tóm tắt của biểu đồ ứng dụng. Khi lớp A (ViewModel) phụ thuộc vào lớp B (Repository), có một dòng trỏ từ A đến B đại diện cho phần phụ thuộc đó.
Việc chèn phần phụ thuộc giúp tạo ra các kết nối này đồng thời cho phép bạn hoán đổi các nội dung triển khai để kiểm thử. Ví dụ: khi kiểm thử một ViewModel phụ thuộc vào một kho lưu trữ, bạn có thể truyền các phương thức triển khai khác nhau của Repository thông qua phương thức giả mạo hoặc mô phỏng để kiểm thử các trường hợp khác nhau.
Thông tin cơ bản về tính năng chèn phần phụ thuộc theo cách thủ công
Phần này trình bày cách áp dụng tính năng chèn phần phụ thuộc theo cách thủ công trong tình huống thực tế của ứng dụng Android. Phần này sẽ hướng dẫn phương pháp tiếp cận lặp lại về cách bạn có thể bắt đầu sử dụng tính năng chèn phần phụ thuộc trong ứng dụng. Phương pháp này sẽ cải thiện cho đến khi đạt đến điểm rất giống với nội dung mà Dagger sẽ tự động tạo cho bạn. Để biết thêm thông tin về Dagger, hãy đọc bài viết Kiến thức cơ bản về Dagger.
Hãy xem luồng là một nhóm màn hình trong ứng dụng tương ứng với một tính năng. Các ví dụ về luồng có thể kể đến là thông tin đăng nhập, đăng ký và quy trình thanh toán.
Khi bao gồm quy trình đăng nhập cho một ứng dụng Android thông thường, LoginActivity sẽ phụ thuộc vào LoginViewModel, phụ thuộc vào UserRepository. Sau đó, UserRepository phụ thuộc vào UserLocalDataSource và UserRemoteDataSource, rồi phụ thuộc vào dịch vụ Retrofit.
LoginActivity là điểm truy cập vào quy trình đăng nhập và người dùng tương tác với hoạt động. Do đó, LoginActivity cần tạo LoginViewModel cùng với tất cả các phần phụ thuộc của nó.
Các lớp Repository và DataSource của luồng sẽ có dạng như sau:
class UserRepository(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource
) { ... }
class UserLocalDataSource { ... }
class UserRemoteDataSource(
private val loginService: LoginRetrofitService
) { ... }
Trong Compose, ComponentActivity là điểm truy cập; quá trình kết nối phần phụ thuộc diễn ra một lần trong onCreate và giao diện người dùng được mô tả bằng các thành phần kết hợp được gọi từ setContent:
class ApiService {
/* Your API implementation here */
}
class UserRepository(private val apiService: ApiService) {
/* Your implementation here */
}
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Satisfy the dependencies of LoginViewModel recursively,
// then pass what the UI needs into setContent.
val apiService = ApiService()
val userRepository = UserRepository(apiService)
setContent {
LoginScreen(userRepository)
}
}
}
@Composable
fun LoginScreen(userRepository: UserRepository) {
val viewModel: LoginViewModel = viewModel(
factory = LoginViewModelFactory(userRepository)
)
// ...
}
Có một vài vấn đề với phương pháp này:
- Các phần phụ thuộc phải được khai báo theo thứ tự. Bạn phải tạo bản sao
UserRepositorytrướcLoginViewModelđể tạo lớp này. - Khó sử dụng lại được đối tượng. Nếu muốn sử dụng lại
UserRepositorytrong nhiều tính năng, bạn phải làm cho lớp này tuân theo mẫu singleton. Mẫu singleton khiến việc kiểm thử trở nên khó khăn hơn vì tất cả các kiểm thử đều có cùng một bản sao singleton.
Quản lý các phần phụ thuộc bằng vùng chứa
Để giải quyết vấn đề sử dụng lại các đối tượng, bạn có thể tạo lớp vùng chứa phần phụ thuộc của riêng mình mà bạn dùng để nhận các phần phụ thuộc. Tất cả các bản sao do vùng chứa này cung cấp đều có thể công khai. Ở ví dụ này, vì bạn chỉ cần một bản sao của UserRepository, nên bạn có thể đặt các phần phụ thuộc của bản sao ở chế độ riêng tư với tuỳ chọn công khai các phần phụ thuộc đó trong tương lai nếu cần cung cấp:
// Container of objects shared across the whole app
class AppContainer {
// apiService and userRepository aren't private and will be exposed
val apiService = ApiService()
val userRepository = UserRepository(apiService)
}
Vì các phần phụ thuộc này được sử dụng trên toàn bộ ứng dụng, nên bạn cần đưa các phần phụ thuộc này vào một vị trí chung mà mọi hoạt động đều có thể sử dụng: lớp Application. Tạo một lớp Application tuỳ chỉnh chứa một bản sao AppContainer.
// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {
// Instance of AppContainer that will be used by all the Activities of the app
val appContainer = AppContainer()
}
Với Compose, cùng một AppContainer vẫn được tạo trong lớp con Application. Bạn có thể truy cập vào đối tượng này trong hoạt động, trước khi gọi setContent hoặc từ bên trong một thành phần kết hợp bằng cách sử dụng LocalContext:
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appContainer = (application as MyApplication).appContainer
setContent {
LoginScreen(appContainer.userRepository)
}
}
}
// Alternatively, read AppContainer from inside a composable:
@Composable
fun LoginScreen() {
val context = LocalContext.current
val appContainer = (context.applicationContext as MyApplication).appContainer
val viewModel: LoginViewModel = viewModel(
factory = LoginViewModelFactory(appContainer.userRepository)
)
// ...
}
Bạn nên truyền các phần phụ thuộc dưới dạng tham số có thể kết hợp thay vì truy cập vào LocalContext từ sâu trong cây. Điều này giúp các thành phần kết hợp có thể kiểm thử và đầu vào của chúng rõ ràng. Phân giải vùng chứa một lần ở gốc màn hình và truyền những gì cần thiết xuống dưới.
Theo cách này, bạn không sở hữu một singleton UserRepository. Thay vào đó, bạn có một AppContainer được chia sẻ trên tất cả hoạt động chứa các đối tượng từ biểu đồ và tạo bản sao các đối tượng mà các lớp khác có thể sử dụng.
Nếu cần LoginViewModel ở nhiều nơi hơn trong ứng dụng, thì việc sở hữu một vị trí tập trung để bạn tạo các bản sao của LoginViewModel sẽ rất hợp lý.
Bạn có thể di chuyển việc tạo LoginViewModel vào vùng chứa, đồng thời cung cấp các đối tượng mới thuộc loại đó cho nhà máy (factory). Mã cho một LoginViewModelFactory sẽ có dạng như sau:
// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
fun create(): T
}
// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory<LoginViewModel> {
override fun create(): LoginViewModel {
return LoginViewModel(userRepository)
}
}
Với Compose, bản cập nhật AppContainer vẫn hiển thị nhà máy. Sau đó, thành phần kết hợp viewModel sẽ sử dụng phương thức khởi tạo để ViewModel được đặt phạm vi thành ViewModelStoreOwner gần nhất (thường là hoạt động lưu trữ hoặc, với Navigation Compose, một mục điều hướng):
// AppContainer exposing the factory (unchanged from the snippet above)
class AppContainer {
// ...
val userRepository = UserRepository(localDataSource, remoteDataSource)
val loginViewModelFactory = LoginViewModelFactory(userRepository)
}
// Compose entry point + screen composable
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appContainer = (application as MyApplication).appContainer
setContent {
LoginScreen(appContainer.loginViewModelFactory)
}
}
}
@Composable
fun LoginScreen(factory: LoginViewModelFactory) {
val viewModel: LoginViewModel = viewModel(factory = factory)
// ...
}
Phương pháp này ổn hơn phương pháp trước, nhưng vẫn còn một số thách thức cần xem xét:
Bạn phải tự quản lý
AppContainer, tạo bản sao cho tất cả các phần phụ thuộc theo cách thủ công.Vẫn còn nhiều mã nguyên mẫu. Bạn cần tạo các nhà máy hoặc tham số theo cách thủ công, tuỳ thuộc vào việc bạn có muốn sử dụng lại một đối tượng hay không.
Quản lý các phần phụ thuộc trong luồng ứng dụng
AppContainer sẽ trở nên phức tạp khi bạn muốn thêm nhiều chức năng hơn trong dự án. Khi ứng dụng có quy mô lớn dần và bạn bắt đầu giới thiệu nhiều luồng tính năng khác nhau, thậm chí còn có nhiều vấn đề phát sinh hơn nữa:
Khi có nhiều luồng khác nhau, bạn có thể muốn các đối tượng chỉ nằm trong phạm vi của luồng đó. Ví dụ: khi tạo
LoginUserData(có thể bao gồm tên người dùng và mật khẩu chỉ sử dụng trong luồng đăng nhập), bạn không muốn lưu giữ lại dữ liệu từ luồng đăng nhập cũ của một người dùng khác. Bạn muốn có một phiên bản mới cho mỗi luồng mới. Bạn có thể đạt được điều đó bằng cách tạo các đối tượngFlowContainerbên trongAppContainer, như được minh hoạ trong ví dụ về mã tiếp theo.Việc tối ưu hoá biểu đồ ứng dụng và vùng chứa luồng cũng có thể khó khăn. Bạn cần nhớ xoá các bản sao không cần thiết, tuỳ thuộc vào luồng bạn đang dùng.
Hãy thêm LoginContainer vào ví dụ về mã. Bạn muốn có thể tạo nhiều bản sao của LoginContainer trong ứng dụng, thế nên hãy biến lớp này trở thành một lớp (thay vì một singleton) với các phần phụ thuộc mà luồng đăng nhập cần từ AppContainer.
class LoginContainer(val userRepository: UserRepository) {
val loginData = LoginUserData()
val loginViewModelFactory = LoginViewModelFactory(userRepository)
}
// AppContainer contains LoginContainer now
class AppContainer {
...
val userRepository = UserRepository(localDataSource, remoteDataSource)
// LoginContainer will be null when the user is NOT in the login flow
var loginContainer: LoginContainer? = null
}
Trong Compose, thời gian tồn tại của vùng chứa luồng được gắn với thành phần chứ không phải Activity lưu trữ. Bạn không cần thay đổi một AppContainer.loginContainer dùng chung, vì các thành phần kết hợp nhận được các phần phụ thuộc của chúng dưới dạng tham số hoặc đọc chúng từ một ViewModel được nâng lên. Bạn có hai tùy chọn:
- Biểu đồ lồng nhau của Navigation Compose (nên dùng cho các quy trình nhiều màn hình).
Đặt tất cả màn hình trong quy trình đăng nhập vào một biểu đồ điều hướng lồng nhau và phạm vi vùng chứa cho
NavBackStackEntrycủa biểu đồ đó. Vùng chứa này được tạo khi người dùng truy cập vào luồng và được xoá khi mục trong ngăn xếp lui bị loại bỏ, không cần lệnh gọi vòng đời thủ công. Để biết thêm thông tin, hãy xem phần Thiết kế biểu đồ điều hướng. rememberở gốc màn hình (đối với quy trình một màn hình hoặc khi bạn không sử dụng Navigation Compose). Tạo vùng chứa bên trongrememberđể vùng chứa được tạo một lần cho mỗi mục nhập vào thành phần và được thu gom rác khi thành phần kết hợp thoát:
@Composable
fun LoginFlow(appContainer: AppContainer) {
val loginContainer = remember(appContainer) {
LoginContainer(appContainer.userRepository)
}
val viewModel: LoginViewModel = viewModel(
factory = loginContainer.loginViewModelFactory
)
// Render the login flow using loginContainer.loginData and viewModel.
}
Kết luận
Chèn phần phụ thuộc là một kỹ thuật hay để tạo các ứng dụng Android có khả năng mở rộng và kiểm thử được. Sử dụng vùng chứa như một cách để chia sẻ các thực thể của lớp trong các phần khác nhau của ứng dụng, đồng thời làm nơi tập trung để tạo các thực thể của lớp bằng cách sử dụng các nhà máy.
Khi ứng dụng trở nên lớn hơn, bạn sẽ bắt đầu thấy việc viết nhiều mã nguyên mẫu (chẳng hạn như nhà máy) có thể dễ xảy ra lỗi. Ngoài ra, bạn còn phải tự quản lý phạm vi và vòng đời của các vùng chứa, tối ưu hoá và loại bỏ các vùng chứa không còn cần thiết để giải phóng bộ nhớ. Thực hiện những điều này không chính xác có thể dẫn đến các lỗi nhỏ và tình trạng rò rỉ bộ nhớ trong ứng dụng.
Trong phần Dagger, bạn sẽ tìm hiểu cách sử dụng Dagger để tự động hoá quy trình này và tạo mã tương tự theo cách thủ công mà bạn đã viết.