Modern Android Architectures

Công ty cổ phần thương mại Vạn Tín Việt

Modern Android Architectures – MVC/MVP/MVVM – Phần 3: Kiến Trúc MVP

Như vậy với 2 phần về xây dựng Kiến trúc trong Android mà mình đã trình bày, bạn đã phần nào hiểu được sự khác nhau giữa các Kiến trúc sơ khởi, đó là Kiến trúc ban đầu theo kinh nghiệm cá nhân, và MVC. Đồng thời hiểu được tại sao chúng ta phải nghiên cứu và áp dụng các Kiến trúc này vào việc xây dựng project của cá nhân chúng ta hoặc của cả team rồi đúng không nào.

Do đó, bước sang phần 3 hôm nay, mình không nhắc lại mục đích của việc tìm hiểu và áp dụng các Kiến trúc này nữa, mà dành thời gian để so sánh sự khác biệt của hai Kiến trúc được nêu tên mà chúng ta biết được: MVC và MVP. Từ đó có một cái nhìn rộng hơn về các Kiến trúc này, giúp bạn tiếp cận nhanh hơn đến các cấu trúc mới mẻ hơn mà chúng ta sẽ đề cập đến ở các phần sau.

Giới Thiệu Về Mô Hình MVP

Như thường lệ, muốn biết cách áp dụng nó như thế nào thì phải hiểu nó là gì cái đã.

MVP là một mô hình Kiến trúc xuất hiện sau MVCMVP ra đời nhằm khắc phục một số khuyết điểm của MVC, mô hình mới này mang đến sự tách biệt giữa các layer rõ ràng hơn, và như vậy khiến việc xây dựng các phương thức test cũng dễ dàng hơn.

Tuy MVP và MVC đều là các Kiến trúc xuất hiện trước khi Android ra đời. Có nghĩa là chúng không phải sinh ra cho Android. Nhưng MVP lại được xem là thích hợp để áp dụng vào các ứng dụng Android hơn là MVC.

Cụ thể các ý liên quan đến MVP trên đây là gì thì mình sẽ nói rõ hơn ở các phần dưới đây nhé.

Quy Tắc MVP

MVP là viết tắt của 3 chữ: Model – View – Presenter.

3 chữ này đại diện cho 3 layer. Các lập trình viên sẽ phải hiểu nguyên tắc của từng layer, để mà tạo ra các lớp tương ứng với các layer đó. Lớp nào thuộc layer nào sẽ tuân thủ theo nguyên tắc của layer đó. Việc đặt tên lớp hay package/directory có thể bao hàm cả tên layer để dễ hiểu và dễ quản lý.

Như vậy là mô hình MVP khác với MVC ở một chữ Presenter thay vì Controller. Dưới đây là chức năng cụ thể của từng layer trong MVP.

  • Model: Layer này giống với model bên MVC. Nó chứa các lớp liên quan đến lưu trữ dữ liệu, hay đảm nhiệm xử lý các nghiệp vụ logic của ứng dụng. Ở phần trước mình có ví  model giống như bộ não của con người, nó giúp xử lý và lưu trữ dữ liệu.
  • View: Cũng khá giống với view bên MVC. Layer này sẽ chứa các lớp liên quan đến hiển thị, và nhận tương tác từ phía người dùng. Và lúc đó mình cũng ví view như là các giác quan, giúp “nghe”“ngửi”“nếm”“nhìn”“cảm giác” từ bên ngoài, đồng thời có thể “nói” ra môi trường bên ngoài sau khi nhận kết quả xử lý. Nhưng trong MVPview được quy định là chỉ có thể tương tác với presenter để truyền nhận dữ liệu, view không được làm việc trực tiếp tới model.
  • Presenter: Layer này làm đúng vai trò của việc truyền dẫn. Rõ ràng khi mà view không được phép “đi đêm” với model thì mọi sự liên kết là do anh presenter đảm nhận rồi.

Kiến trúc MVP được vẽ lại theo sơ đồ sau.

Sơ đồ kiến trúc MVP
Sơ đồ kiến trúc MVP

Mối Liên Hệ Giữa MVC Và MVP

Đến đây thì bạn đã hiểu rõ hơn về MVP rồi. Tuy nhiên mình cũng muốn tóm lược lại sự liên quan giữa hai Kiến trúc MVC và MVP này.

Đầu tiên, như mình có nói ở đầu bài viết, MVP là một mô hình được xây dựng dựa trên MVC. Vì vậy mà mục tiêu của chúng khá giống nhau, chúng giúp tách bạch vai trò của các khối công việc trong một project phần mềm. Vừa phục vụ cho việc quản lý, xây dựng, bảo trì sản phẩm, vừa hỗ trợ tốt việc xây dựng các phương thức Test.

Tuy MVC khác MVP ở chữ C và P. Nhưng chung quy lại controller và presenter lại có cùng một mục đích, đó là cầu nối giữa view và model. Có chăng, MVP mượn nghĩa của từ presenter, để presenter thể hiện rõ ràng là sự kết nối (presenter chỉ mang tính thông báo khi được view và model yêu cầu), hơn là controller thể hiện sự điều khiển (controller có thể tự nó điều khiển view và model nếu muốn).

Còn một điểm nâng cấp đáng giá nữa của MVP so với MVC. Đó là ở MVC cho phép controller được phép “phục vụ” nhiều view cùng lúc. Thì MVP lại quy định chặt chẽ hơn khi cho rằng một presenter nên chỉ “phục vụ” một view mà thôi. Bạn sẽ nắm được ý này khi tiến hành xây dựng MVP như project dưới đây.

MVP Trong Android

Chúng ta lại nói tới vấn đề này, như đã nói với MVC.

Ở trên kia mình có nói MVP được xem là thích hợp hơn MVC trong việc lập trình hứng dụng Android.

Sự thích hợp này thể hiện đầu tiên ở vai trò của view. Nếu như ở MVC bài trước mình có trình bày sự tranh cãi về việc các lớp Activity, Fragment hay các View khác trong Android liệu có thuộc view, và thuộc luôn controller hay không. Thì với MVP, Activity, Fragment hay các View khác trong Android chỉ đảm nhận vai trò của viewPresenter khi này chỉ chứa các lớp liên quan đến kết nối, những thứ mà view không cần làm sẽ được mang vào presenter.

Ngoài ra các lớp thuộc presenter hay model ở mô này cũng không được phép kế thừa từ các lớp thuộc thư viện Android, như Activity hay Fragment. Hơn nữa, chúng cũng không nên có các thuộc tính liên quan đến thư viện Android như Context, View hay Intent gì cả. Ràng buộc này giúp presenter tách biệt hơn so với view, đồng thời cũng dễ dàng xây dựng các phương thức test hơn khi áp dụng vào Android.

Bắt Tay Xây Dựng Ứng Dụng

Lý thuyết nói nhiều quá. Giờ là lúc chúng ta cùng nhau chỉnh sửa lại ứng dụng của bài hôm trước để xem mô hình MVP ở bài này trông như thế nào nhé.

Nếu bạn chưa xây dựng ứng dụng mẫu từ bài trước, thì mình khuyên bạn nên đọc qua các bài trước này. Hoặc bạn có thể lấy nhanh source code từ Github của bài hôm trước theo link này. Dựa trên ứng dụng theo kiến trúc MVC của bài trước, chúng ta sẽ chỉnh sửa lại, hay kỹ thuật gọi là refactor code, để trở thành kiến trúc MVP của bài hôm nay.

Nào giờ hãy mở project ModernAndroidArchitectures ra.

Tổng Quan Project

Cái này mình giới thiệu lại project, đã được nói tới ở mục này của bài đầu tiên rồi.

Project của tất cả bài viết trong chủ đề Modern Android Architectures này đều có chung một kết quả màn hình như sau.

Màn hình ứng dụng mẫu
Màn hình ứng dụng mẫu

Ứng dụng sẽ kết nối đến Web Service để lấy về danh sách các quốc gia kèm thủ đô của nó. Web Service này được xây dựng sẵn trên trang restcountries.com. Ngoài việc hiển thị danh sách các quốc gia, ứng dụng còn có chức năng tìm kiếm theo tên quốc gia. Khi click vào bất kỳ quốc gia nào trên danh sách sẽ hiển thị một message dạng Toast cho biết tên và thủ đô của quốc gia vừa click. Vậy thôi.

Tổng Quan MVP Trong Project Này

Trước hết hãy cùng xem lại các lớp và cách tổ chức chúng vào các package của bài trước sẽ như thế này.

Kiến trúc MVC của bài trước
Kiến trúc MVC của bài trước

Vẫn với mục đích dễ dàng tiếp cận hơn tới mô hình MVP, mình cũng sẽ tạo các package viewmodel và presenter rồi để các lớp tương ứng vào.

Tuy nhiên trong thực tế, các ứng dụng sẽ có rất nhiều màn hình và chức năng khác nhau, nên các project thực tế lúc này có thể không cần phải chia theo viewmodel và presenter như ứng dụng hôm nay, việc chia theo 3 layer này sẽ khiến mỗi layer chứa rất nhiều lớp, làm cho việc quản lý trở nên cồng kềnh. Khi đó bạn có thể chọn chia package theo từng chức năng cụ thể, như maindetailsearch,… mỗi chức năng như vậy chứa đủ các lớp ở cả 3 layer viewmodel và presenter vào trong đó chẳng hạn.

Ghi chú thêm cho bạn

Quay lại ví dụ cụ thể của chúng ta, với mong muốn chia các lớp vào trong chỉ 3 layer là viewmodel và presenter thì các lớp tương ứng của chúng như sau.

Các lớp trong project tương ứng với MVP
Các lớp trong project tương ứng với MVP

Vẫn với lưu ý là các lớp khác liên quan đến kết nối Web Service, như CountriesApi, hay CountriesService, mình vẫn để trong package networking. Mình xem các lớp này không thuộc viewmodel hay presenter nên không đưa chúng vào sơ đồ trên.

Bạn có thể thấy, so với MVC của bài hôm trước thì các lớp bên trong view và model ở MVP hôm nay hầu như không thay đổi gì. Đặc biệt là model, mình không sửa chữa gì liên quan đến model ở bài hôm nay, nên nếu bạn nào muốn biết code của CountryModel và NameModel ra sao thì có thể xem phần này của bài hôm trước.

Với package view thì lớp CountriesActivity của bài hôm nay sẽ thay đổi một chút để có thể làm việc tốt với presenter thay vì controller của bài hôm trước.

Còn package controller của hôm trước nay đã đổi thành presenterCountriesController cũng thay thành CountriesPresenter. Ngoài việc thay đổi tên này ra thì cách làm việc giữa presenter và controller dĩ nhiên sẽ có khác nhau nhiều như các ý so sánh giữa hai thành phần này ở mục trên của bài viết.

Tuy nhiên, như sơ đồ MVP trên đây, presenter rất cần phải có sự kết nối chặt chẽ với view và model. Mà quy định lại không cho presenter khai báo hay gọi trực tiếp đến đến view, thì làm sao chúng nó liên lạc được. Chính vì vậy mà CountriesContract xuất hiện bên trong sơ đồ lớp của khối presenter trên đây. CountriesContract sẽ chứa đựng các interface, chính các view và presenter sẽ implement các interface tương ứng, và do vậy chúng hoàn toàn có thể giao tiếp với nhau mà không bị phụ thuộc vào khai báo biến của nhau bên trong mỗi lớp.

Theo kinh nghiệm cá nhân của mình, để giúp cho việc tổ chức và kết nối được dễ dàng, thì mỗi một view sẽ có một presenter tương ứng, và như vậy chúng sẽ phải có các interface riêng. Nói tóm lại bộ ba CountriesActivity-CountriesPresenter-CountriesContract sẽ đi cùng với nhau. Sau này giả sử bạn xây dựng màn hình hiển thị detail chẳng hạn, thì DetailActivity-DetailPresenter-DetailContract sẽ đi cùng với nhau, cứ như vậy xây dựng lên. Còn các lớp trong model thì có thể được dùng chung giữa các view và presenter nên chúng không cần có các bộ interface tương ứng

Ghi chú thêm cho bạn

Lát nữa đến phần code bên dưới bạn sẽ hiểu hơn.

Xây Dựng Layer Presenter

Chắc chắn chúng ta phải nói đến tâm điểm của bài hôm nay, là presenter, đầu tiên nhất rồi.

Trước hết bạn không cần phải tạo mới package hay lớp gì cả. Hãy theo hướng dẫn của các bài thực hành trước để đổi tên package controller thành presenter, và đổi tên lớp CountriesController thành CountriesPresenter của bài hôm nay. Hình ảnh project sau khi đổi tên này như sau.

Kiến trúc project khi đổi tên package và lớp
Kiến trúc project khi đổi tên package và lớp

Như đã nói, chúng ta cần xây dựng các interface để làm cầu nối giữa CountriesActivity và CountriesPresenter. Các interface này sẽ nằm trong CountriesContract. Bạn hãy tạo một lớp CountriesContract với nội dung như sau.

class CountriesContract {
interface PresenterInterface {
// Interface này dành cho CountriesPresenter
}
interface ViewInterface {
// Interface này dành cho CountriesActivity
}
}

Bạn đã “hơi” tưởng tượng ra kịch bản chưa nào. Do CountriesActivity và CountriesPresenter không được phép làm việc trực tiếp với nhau (như cách mà CountriesController làm với CountriesActivity hôm trước), nên chúng phải tự implement các interface, để “đối phương” quản lý nhau thông qua các interface này. CountriesContract chỉ là một lớp được tạo ra để chứa đựng các interface mà thôi, như vậy chúng ta sẽ rất dễ quản lý các interface này do chúng đều nằm chung vào một lớp. Nếu bạn có từng tham khảo qua MVP đâu đó thì có thể thấy một số tài liệu còn đẻ ra khá nhiều interface khác, như ModelInterface để cho các lớp model implement luôn, tạo ra một sự liên kết giữa các layer chặt hơn, thì cứ để các interface tương ứng này vào cùng một lớp XxxContract cho dễ quản lý nhé.

Giờ thì bạn có thể mở CountriesPresenter lên để tiến hành implement interface của nó như dòng tô sáng sau.

class CountriesPresenter(
private var view: CountriesActivity,
private val apiService: CountriesApi
) : CountriesContract.PresenterInterface {
fun onFetchCountries() {
apiService.let {
it.getCountries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
view.onSuccessful(result)
}, { error ->
view.onError()
})
}
}
}

Sau đó, nó không được phép làm việc trực tiếp với CountriesActivity nữa, mà làm việc thông qua interface của view chính là CountriesContract.ViewInterface. Điều này giúp cho CountriesPresenter không cần biết cụ thể View mà nó muốn tương tác đến là gì, nó chỉ cần biết đến interface và gọi các abstract function tương ứng đã được định nghĩa bên trong interface này. Sau này nếu có bất kỳ View nào implement interface này, View đó sẽ phải hiện thực các abstract function. Để hiện thực ý trên đây, chúng ta cần chỉnh sửa cách dùng thuộc tính view thành viewInterface như sau.

class CountriesPresenter(
private var viewInterface: CountriesContract.ViewInterface,
private val apiService: CountriesApi
) : CountriesContract.PresenterInterface {
fun onFetchCountries() {
apiService.let {
it.getCountries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
viewInterface.onSuccessful(result)
}, { error ->
viewInterface.onError()
})
}
}
}

Nếu bạn thấy báo lỗi ở 2 dòng 11 và 13 ở code trên là bởi vì trong CountriesContract.ViewInterface lúc này chưa khai báo 2 abstract function tương ứng. Nào chúng ta cùng nhau quay lại CountriesContract để cùng thêm 2 function sau vào CountriesContract.ViewInterface nhé. À ngoài ra chúng ta cũng cần phải thêm onFetchCountries() vào CountriesContract.PresenterInterface để đảm bảo ràng buộc hơn cho presenter ở giai đoạn này.

class CountriesContract {
interface PresenterInterface {
fun onFetchCountries()
}
interface ViewInterface {
fun onSuccessful(result: List<CountryModel>)
fun onError()
}
}

Trước khi chỉnh sửa cho view thì bạn nhớ đảm bảo thêm override cho onFetchCountries() ở CountriesPresenter khi này để hết báo lỗi từ trình biên dịch.

class CountriesPresenter(
private var viewInterface: CountriesContract.ViewInterface,
private val apiService: CountriesApi
) : CountriesContract.PresenterInterface {
override fun onFetchCountries() {
// Code chỗ này không quan tâm, mình ẩn đi
}
}

Xây Dựng Layer View

View, cụ thể khi này là CountriesActivity sẽ có khác đôi chút. Do với MVC của bài học hôm trước chúng ta đã tách các xử lý không liên quan đến view ra khỏi activity này, nên bài hôm nay chúng ta chỉ cần thay thế controller thành presenter, và cùng xem xét xem việc nâng cấp từ cách làm việc giữa view-controller và view-presenter khác nhau như thế nào thôi.

Đầu tiên, theo đúng “hợp đồng tác chiến”, bạn phải khai báo CountriesActivity implement CountriesContract.ViewInterface. Điều này đảm bảo CountriesActivity sẽ phải hiện thực tất cả các phương thức mà CountriesPresenter cần đến ở các code trên đây. Bạn hãy mở CountriesActivity lên và tiến hành chỉnh sửa ở những dòng tô sáng sau.

class CountriesActivity : AppCompatActivity(), CountriesContract.ViewInterface {
 
// Code chỗ này không quan tâm, mình ẩn đi
 
override fun onSuccessful(result: List<CountryModel>) {
// Code chỗ này không quan tâm, mình ẩn đi
}
 
override fun onError() {
// Code chỗ này không quan tâm, mình ẩn đi
}
}

Dĩ nhiên thay đổi đầu tiên đối với code của CountriesActivity trên như đã nói, là implement CountriesContract.ViewInterface. Và do đó nó cần phải override lại onSuccessful() và onError() đã được khai báo bên trong interface này. Có một điều lưu ý rằng do hai phương thức onSuccessful() và onError() cũng đã được chúng ta xây dựng sẵn từ bài trước rồi, hôm nay chỉ cần thêm vào từ khóa override vào trước chúng nữa là hết báo lỗi từ trình biên dịch.

Thay đổi tiếp theo, chúng ta cần thay việc khai báo một controller của bài hôm trước thành presenter của bài hôm nay như sau.

class CountriesActivity : AppCompatActivity(), CountriesContract.ViewInterface {
 
private lateinit var binding: ActivityCountriesBinding
private lateinit var countriesPresenter: CountriesPresenter
private val countriesAdapter = CountriesAdapter(arrayListOf())
private var countries: List<CountryModel> = listOf()
 
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCountriesBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
 
val apiService = CountriesService.create()
countriesPresenter = CountriesPresenter(this, apiService)
 
// Code chỗ này không quan tâm, mình ẩn đi
 
onFetchCountries()
}
 
private fun onFetchCountries() {
binding.listView.visibility = View.GONE
binding.progress.visibility = View.VISIBLE
binding.searchField.isEnabled = false
 
countriesPresenter.onFetchCountries()
}
 
// Code chỗ này không quan tâm, mình ẩn đi
}

Bạn nhận thấy chỉ có một chút thay đổi nhỏ là chuyển tên biến countriesController sang countriesPresenter thôi đúng không nào. Tuy nhiên, với thay đổi nhỏ này Kiến trúc của chúng ta đã chuyển sang một cách làm việc hoàn toàn mới so với trước. Tuy dòng tô sáng việc khởi tạo CountriesPresenter(this, apiService) vẫn truyền 2 tham số như với việc khởi tạo CountriesController(this, apiService), nhưng this ở bài hôm nay lại là một interface mà CountriesActivity đang implement, nó không cụ thể là CountriesActivity nữa. Điều này khiến CountriesPresenter không bị phụ thuộc vào bất kỳ một View cụ thể nào, nó chỉ làm việc với interface. Sau này nếu có bất kỳ View nào muốn dùng chung CountriesPresenter này, nó chỉ việc implement CountriesContract.ViewInterface mà thôi, việc thay đổi này không ảnh hưởng đến CountriesActivity hay CountriesPresenter đã được xây dựng trước đó. Quả là một thay đổi nhỏ nhưng kết quả to lớn đúng không nào.

Đến lúc này bạn đã có thể thực thi chương trình để xem kết quả được rồi. Tuy nhiên nếu để ý kỹ, chúng ta sẽ thấy view lúc bấy giờ vẫn còn làm việc trực tiếp với model, ở chỗ khi bạn click vào một phần tử trong danh sách quốc gia, view vẫn gọi trực tiếp để model trả về thông tin cần hiển thị. Tuy việc làm này khá nhỏ nhặt và chúng ta có lẽ cũng không cần chỉnh sửa gì, nhưng hãy cứ làm theo nguyên tắc, view chỉ làm việc với presenter mà thôi. Việc cố gắng tuân thủ nguyên tắc chặt chẽ này giúp chúng ta không phá vỡ cấu trúc MVP khi xây dựng nhiều giải thuật phức tạp hơn sau này.

Để tách bạch view khỏi model mà chúng ta đang định làm, trước tiên hãy vào CountriesContract xây dựng các phương thức “hợp đồng” để hai chú này có công cụ mà làm việc với nhau.

class CountriesContract {
interface PresenterInterface {
fun onFetchCountries()
fun getCountryInfo(country: CountryModel)
}
interface ViewInterface {
fun onSuccessful(result: List<CountryModel>)
fun onError()
fun showMessage(message: String)
}
}

Với việc “hợp đồng” được bổ sung này, bạn muốn bất cứ lớp nào implement PresenterInterface cũng phải có thêm phương thức getCountryInfo(). Vâng vậy hãy qua CountriesPresenter làm ngay đi nào.

class CountriesPresenter(
private var viewInterface: CountriesContract.ViewInterface,
private val apiService: CountriesApi
) : CountriesContract.PresenterInterface {
override fun onFetchCountries() {
// Code chỗ này không quan tâm, mình ẩn đi
}
 
override fun getCountryInfo(country: CountryModel) {
val countryInfo = country.getCountryInfo()
viewInterface.showMessage(countryInfo)
}
}

Bên trong getCountryInfo() của CountriesPresenter, chúng ta lấy thông tin của quốc gia cần hiển thị từ CountryModel, rồi sau đó, không cần biết View của chúng ta là gì và có implement phương thức showMessage() hay chưa, nó cứ gọi đến phương thức này để kêu View sắp implement phương thức này hoạt động.

Cũng như vậy, theo “hợp đồng”CountriesActivity giờ cũng phải thay đổi như sau.

class CountriesActivity : AppCompatActivity(), CountriesContract.ViewInterface {
 
// Code chỗ này không quan tâm, mình ẩn đi
 
override fun onCreate(savedInstanceState: Bundle?) {
// Code chỗ này không quan tâm, mình ẩn đi
 
countriesAdapter.setOnItemClickListener(object : CountriesAdapter.OnItemClickListener {
override fun onItemClick(country: CountryModel) {
countriesPresenter.getCountryInfo(country)
}
})
 
// Code chỗ này không quan tâm, mình ẩn đi
}
 
// Code chỗ này không quan tâm, mình ẩn đi
 
override fun showMessage(message: String) {
Toast.makeText(this@CountriesActivity, message, Toast.LENGTH_SHORT).show()
}
}

View gọi đến presenter để nhờ presenter lấy hộ thông tin của quốc gia, rồi chờ presenter trả về kết quả hiển thị ra Toast ở showMessage(). Bạn có thấy rõ kịch bản của MVP chưa nào.

Giờ thì bạn đã có thể thực thi chương trình để xem kết quả được rồi đó. Tuy kết quả của chương trình ở bài hôm nay không khác bài trước, nhưng chương trình của chúng ta đã được chuyển sang một kiến trúc mới hợp lý hơn, dễ xây dựng các phương thức test hơn.

Kết Luận

Cũng như MVCMVP cũng đang dần trở nên lỗi thời do Android đã chính thức đưa ra một nền tảng kiến trúc mới, cùng các bài viết hướng dẫn chi tiết đi kèm, giúp các lập trình viên Android giờ đây đã có thể tự tin tìm hiểu và đi theo hướng “chính chủ” này cho các ứng dụng của mình. Tuy nhiên, theo mình nghĩ, biết MVC và MVP ở giai đoạn này vẫn không phải là một điều rỗi hơi gì. Nhiều ứng dụng được chia sẻ trên mạng vẫn còn đó các kiến trúc cũ, nếu bạn không biết về chúng, thì việc đọc hiểu code ở các ứng dụng này cũng gặp nhiều rắc rối. Ngoài ra, khi hiểu về MVC, đặc biệt MVP của bài hôm nay, bạn lại càng có thêm các kỹ năng mới, chẳng hạn việc giao tiếp giữa các lớp thông qua interface, một kỹ thuật đặc biệt trong lập trình ứng dụng chung không riêng gì Kotlin hay Java, giúp cho các lớp thoát khỏi sự phụ thuộc trực tiếp vào nhau, và như vậy trở nên vững chắc hơn, và dễ xây dựng các phương thức test hơn nữa. Bài học sau chúng ta cùng đi qua MVVM để xem kiến trúc mới này hay ho hơn các kiến trúc khác ở chỗ nào nhé.

 

Modern Android Architectures – MVC/MVP/MVVM – Phần 1: Giới Thiệu Các Mô Hình Kiến Trúc

Thực sự thì rất lâu rồi mình không viết bài nào mới cả. Điều này làm mình rất áy náy và ngứa ngáy. Mình sợ để lâu nữa sẽ không ai thèm ngó ngàng gì đến blog của mình nữa. Đùa thôi chứ mình biết nhiều bạn vẫn thường vào blog của mình để xem có bài viết mới chưa í mà.

Do đó ngay khi quyết định quay lại và duy trì lượng bài viết mà mình đã giữ nhịp trong suốt mấy năm qua, mình muốn nói ngay đến chủ đề mà nhiều bạn cũng đã đặt câu hỏi từ lâu, đó là chủ đề về Modern Android Architectures. Chính là nói về các kiến trúc theo các khuôn mẫu (pattern) MVCMVP hay MVVM.

Với Phần 1 hôm nay mình sẽ nói sơ qua về tổng quan các khuôn mẫu kiến trúc này, chúng là gì và tại sao chúng ta lại cần một khuôn mẫu như thế này. Và chúng ta sẽ cùng xây dựng một ứng dụng nhỏ không phải là bất kỳ khuôn mẫu nào trong số cái mình đã kể ra trên đây, để có thể so sánh với khuôn mẫu ở các bài viết sau đó mà.

Modern Android Architectures Là Gì?

Modern Android Architectures hay nhiều tài liệu cũng chỉ gọi là Architecture, hay Kiến trúc, gì gì đó. Nói chung ở mức tổng quan nhất thì các tên gọi về lĩnh vực này mình thấy nó không có sự thống nhất với nhau. Tuy nhiên chúng đều đang nói đến một vấn đề, đó là cách bạn tạo ra và tổ chức các lớp bên trong một ứng dụng Android theo một phân cấp thư mục (hay package) như thế nào. Nói cho dễ hiểu hơn, là khi bạn đã hiểu và theo sát một Kiến trúc nào đó, thì bạn sẽ biết với một màn hình chức năng của ứng dụng, bạn sẽ phải tạo ra các lớp nào (ngoài lớp giao diện Activity hay Fragment), và để chúng vào các thư mục nào nữa. Mục đích tại sao phải theo các Kiến trúc này thì mình nói sau.

Chung quy lại cộng đồng lập trình viên tổng hợp thành 3 Kiến trúc được xem là phổ biến và thích hợp nhất cho việc xây dựng ứng dụng Android, được nhắc đến với 3 cái tên MVCMVP và MVVM.

Chà, nói vậy thì sẽ có khá nhiều Kiến trúc, biết vận dụng Kiến trúc nào bây giờ? Đó cũng là câu hỏi mà rất rất nhiều các lập trình viên đã đặt câu hỏi trên các diễn đàn hỏi đáp nổi tiếng. Cũng có rất nhiều phản hồi nhưng rốt cuộc thì sử dụng Kiến trúc nào là do bạn cả thôi. Mỗi loại Kiến trúc sẽ có cái hay, cái dở riêng. Mỗi loại sẽ phù hợp với từng quy mô dự án riêng. Và tùy theo sự thích thú của bạn vào Kiến trúc nào mà bạn sẽ quyết định dùng nó cho dự án mà bạn hoặc công ty bạn đang phát triển nữa.

Tại Sao Phải Tìm Hiểu Về Modern Android Architectures

Chắc hẳn câu hỏi này hiện lên trong đầu bạn ngay khi bạn đọc tiêu đề của bài viết này. Tất nhiên rồi, các Kiến trúc này là cái quái gì mà bắt bạn phải bận tâm?

Đúng vậy, có thể nói những năm đầu tiếp cận vào lập trình, mình cũng chẳng để ý lắm đến việc tổ chức các lớp bên trong ứng dụng theo kiểu Kiến trúc gì đó đâu, mặc dù khi đó mình biết có những người bạn của mình đã và đang xây dựng ứng dụng của họ theo một Kiến trúc nhất định nào đó rồi. Và khi đó mình đã nghe nhiều lời khen từ những người “đi trước” đó. Tuy nhiên mình vẫn không thèm áp ụng Kiến trúc nào cả, một phần vì… lười tìm hiểu, phần còn lại là vì cho dù chẳng cần biết một Kiến trúc nào, thì mình cũng xây dựng thành công nhiều ứng dụng đó thôi. Chắc nhiều bạn cũng đồng ý với suy nghĩ này.

Tuy nhiên, mình viết bài này một phần cũng mong các bạn nhanh chóng tiếp cận và tập làm việc theo các nguyên tắc nhất định, nhất là lập trình, và cụ thể trong bài này là các Kiến trúc của ứng dụng.

Thứ nhất, các Kiến trúc mà chúng ta đang tìm hiểu nó đều là các Kiến trúc theo Khuôn mẫu (Pattern). Mà bạn biết đã gọi là Pattern thì đó là các nguyên tắc, các ràng buộc, các hướng dẫn với mục đích chính là làm rõ nghĩa hơn những gì bạn xây dựng. Và giảm thời gian bảo trì, tức giảm thời gian và công sức khi bạn chỉnh sửa hay thêm mới các tính năng vào các ứng dụng mà bạn đã xây dựng từ lâu. Thực ra nếu bạn từng xây dựng một ứng dụng rất lớn, đến cả trăm, thậm chí cả ngàn lớp, hoặc có quá nhiều dòng code trong các lớp của project của bạn, thì bạn mới thấy rõ nhu cầu của việc tổ chức Kiến trúc ngay từ đầu (mà vậy thì đã quá muộn để thay đổi).

Thứ hai, vì nó là các Khuôn mẫu, nên nó giúp những người làm cùng với bạn hiểu rõ bạn đã làm gì, nếu như họ đọc code của bạn đã viết. Hoặc bạn cũng sẽ dễ dàng nắm bắt ý tưởng và bắt tay vào sửa lỗi hoặc xây dựng thêm các tính năng từ code của người khác (và của chính bạn nữa).

Thứ ba, việc tổ chức lớp ra từng thành phần theo từng loại Kiến trúc như vậy sẽ giúp bạn dễ dàng xây dựng các Phương thức Test cho riêng các lớp chức năng chuyên biệt. Đây là một vấn đề khác mà mình hi vọng sẽ có chuỗi bài viết riêng về nó sau này.

Bắt Tay Xây Dựng Ứng Dụng

Chúng ta sẽ không nói lý thuyết suông. Với bài số 1 này chúng ta cùng nhau xây dựng một ứng dụng “thuần túy” của Android, tức không hề có bất cứ Kiến trúc nào cả. Nói như vậy không có nghĩa là chúng ta xây dựng “đại” một project với các lớp được để chung hết vào một package. Chúng ta vẫn phải tổ chức các lớp theo các package riêng biệt, có điều sự tổ chức này là “tùy tiện” theo sở thích của chúng ta mà thôi. Bạn sẽ thấy với cùng một ứng dụng hôm nay, chúng ta sẽ thay đổi chúng theo các Kiến trúc ở các bài sau như thế nào nhé.

Chúng ta sẽ xây dựng một ứng dụng có kết nối với Web Service. Ồ sẽ không cần bạn phải tạo một Web Service để mobile kết nối vào đâu, mình thấy trên mạng có khá nhiều các Web Service được xây dựng sẵn để chúng ta thực tập, một trong số đó có thể kể đến là trang restcountries.com này. Web Service này cho chúng ta các RESTful API, khi kết nối đến sẽ trả về các dữ liệu liên quan đến các quốc gia trên thế giới.

Để kết nối với một Web Service thông qua một RESTful API thì mình sẽ dùng đến Retrofit kết hợp với RxJava. Nếu các bạn muốn biết cách sử dụng Retrofit và RxJava thế nào thì có thể xem ở link mình để sẵn. Bài viết này mình sẽ không nói đến Retrofit hay RxJava, bạn có thể code theo hướng dẫn của bài học để ứng dụng chạy được, để hiểu các Kiến trúc trong lập trình là chính, mình sẽ nói đến các kiến thức liên quan này ở các bài cụ thể khác sau nhé.

Tổng Quan Project

Project của tất cả bài viết trong chủ đề Modern Android Architectures này đều có chung một kết quả màn hình như sau.

Màn hình ứng dụng mẫu
Màn hình ứng dụng mẫu

Như đã nói, màn hình chính sẽ kết nối với Web Service. Mục đích của project chỉ để lấy về danh sách các quốc gia kèm tên thủ đô của mỗi quốc gia đó rồi hiển thị lên màn hình chính này. Chúng ta cũng xây dựng một vùng tìm kiếm ở phía trên để hỗ trợ người dùng tìm kiếm nhanh một quốc gia thay vì phải cuộn và tìm trên danh sách. Khi click vào một quốc gia nào đó sẽ hiển thị một message dạng Toast, lát nữa khi xây dựng ứng dụng bạn sẽ biết chúng ta sẽ hiển thị gì cho Toast sau nhé.

Còn bây giờ thì hãy tạo mới project nào.

Tạo Mới Project

Bạn hãy tạo một project, rồi đặt tên ModernAndroidArchitectures, hoặc bất cứ tên nào mà bạn thích nhé. Project này sẽ được viết bằng ngôn ngữ Kotlin.

Tạo mới Project
Tạo mới Project

Trong trường hợp bạn muốn biết nhiều hơn về cách tạo một project Android thì có thể xem thêm ở bài viết này.

Khai Báo Thư Viện

Bước đầu tiên nhất, chúng ta cần phải cấu hình cho build.gradle sao cho có thể sử dụng được thư viện RetrofitRxJava và RecyclerView (để hiển thị danh sách). Bạn hãy mở file build.gradle ở cấp độ module lên và thêm vào các dòng tô sáng sau vào khối dependencies.

dependencies {
 
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
 
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.8.1'
implementation 'com.squareup.retrofit2:converter-scalars:2.1.0'
 
// RxJava
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation 'io.reactivex.rxjava2:rxjava:2.2.21'
 
// RecyclerView
implementation 'androidx.recyclerview:recyclerview:1.2.1'
 
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

Cũng trong file này, bạn hãy xem trong khối android có khai báo đoạn tô sáng dưới đây không, nếu không có thì hãy thêm vào. Với khai báo này chúng ta sẽ dễ dàng sử dụng View Binding để tương tác với các view ở XML sau này.

android {
...
buildFeatures {
viewBinding = true
}
}

Giờ thì bạn có thể tìm và nhấn nút sync lại file build.gradle này.

Tiếp theo, bạn hãy mở file Manifest lên để chúng ta đăng ký quyền kết nối Internet cho ứng dụng. Không có quyền này thì ứng dụng chúng ta sẽ không thể kết nối đến Web Service để lấy kết quả JSON về đâu nên bạn đừng quên bước này nhé. Việc xin quyền được khai báo bằng dòng tô sáng như sau.

<?xml version="1.0" encoding="utf-8"?>
package="com.yellowcode.modernandroidarchitectures">
 
<uses-permission android:name="android.permission.INTERNET" />
 
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".activity.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
 
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
 
</manifest>

Tổ Chức Kiến Trúc Theo Kinh Nghiệm Cá Nhân

Vâng chúng ta sẽ dành sự tập trung cho các Kiến trúc MVCMVPMVVM ở các phần sau. Phần này mình xin phép được đưa ra “kiến trúc” theo kinh nghiệm của mình ở nhiều project trước khi chính thức tìm hiểu và áp dụng một Kiến trúc chuẩn. Mình gọi tắt Kiến trúc-theo-kinh-nghiệm-cá-nhân này là Kiến trúc custom cho ngắn gọn nhé.

Tuy nói là theo kinh nghiệm cá nhân, nhưng việc tổ chức theo dạng này đã được mình tham khảo khá nhiều, và có đưa vô một vài kinh nghiệm bản thân. Và mình cũng có hướng dẫn các bạn tổ chức theo kiểu Kiến trúc này ở nhiều nơi khác. Bạn cũng sẽ thấy quen thôi với cách tổ chức này. Hãy xem nào.

Bạn hãy vào tab bên trái Android Studio và chuyển view về dạng Project trước cho việc cấu trúc được dễ dàng hơn. Sau đó hãy mở thư mục chứa source code (nơi chứa file MainActivity). Click chuột phải lên thư mục này và chọn New > Package.

Tạo package con chứa source code
Tạo package con chứa source code

Cửa sổ New Package xuất hiện sau đó bạn hãy điền vào tên cho thư mục (chính là package) này là activity.

Tạo package có tên activity
Tạo package có tên activity

Sau khi tạo thư mục activity ở bước trên. Bạn hãy lặp lại các bước này cho đến khi tạo được tất cả các thư mục cần thiết cho Kiến trúc custom, chúng bao gồm: activityadaptermodelnetworking. Như hình sau.

Kiến trúc Custom
Kiến trúc Custom

Nhìn vào các thư mục vừa tạo, chắc hẳn bạn cũng hiểu ý đồ tổ chức các file source code trong ứng dụng về thành các Kiến trúc như thế nào rồi đúng không nào. Mình sẽ nói tóm tắt cách tổ chức này như sau.

  • activty: đơn giản chứa đựng tất cả Activity trong project này. Một lát nữa chúng ta sẽ dời MainActivity vào thư mục này.
  • adapter: chứa đựng tất cả adapter của project vào trong thư mục này.
  • model: chứa các lớp data, các lớp này chứa đựng dữ liệu trả về khi chúng ta gọi API.
  • networking: chứa các lớp liên quan đến kết nối Internet.

Ngoài ra thì ví dụ này còn thiếu một thư mục quan trọng, đó là fragment. Vì chỉ có 1 màn hình duy nhất nên mình không tạo thư mục này. Tuy nhiên với dự án thực tế của các bạn thì dĩ nhiên sẽ phải có fragment để mà chứa tất cả các Fragment trong project vào một chỗ rồi.

Bước cuối cùng trong việc tổ chức Kiến trúc custom này là phải mang MainActivity vào trong thư mục activity. Việc này rất đơn giản, vẫn trong cửa sổ Project, bạn hãy kéo file MainActivity vào thư mục activity như hình minh họa sau.

Kéo file MainActivity thả vào thư mục activity
Kéo file MainActivity thả vào thư mục activity

Việc kéo thả này sẽ xuất hiện một popup xác nhận. Bạn hãy cẩn thận kiểm tra mục To package đã hiển thị đúng đường dẫn đến tận activity là được. Để tránh trường hợp bạn kéo nhầm vào thư mục khác í mà. Cơ mà nếu có kéo nhầm thì kéo lại vẫn không sao nhé.

Cửa sổ xác nhận di chuyển MainActivity
Cửa sổ xác nhận di chuyển MainActivity

Sau khi nhấn Refactor ở cửa sổ trên thì MainActivity của chúng ta khi này đã nằm đúng thư mục rồi đó.

Bạn hãy nhớ các bước cấu trúc cho Kiến trúc của project ở bài hôm nay nhé, các phần sau chúng ta sẽ cấu trúc chúng theo các Kiến trúc MVCMVP hay MVVM cụ thể đấy.

Xây Dựng Các Lớp Kết Nối Web Service

Trước khi bắt tay vào viết các dòng code kết nối. Nguyên tắc đầu tiên khi xây dựng chức năng kết nối này là phải xây dựng trước các lớp chứa data trả về.

Các Lớp Chứa Data

Mà muốn viết các lớp chứa data này, bạn phải biết nội dữ liệu của kết quả trả về. Với ví dụ của bài hôm nay bạn sẽ nhận về một JSON.

Như đã nói, chúng ta sẽ lấy dữ liệu từ trang https://restcountries.com/, trang này chứa đựng rất nhiều API cho chúng ta thử nghiệm. Nhưng chúng ta chỉ cần duy nhất API này thôi: https://restcountries.com/v3.1/all.

Bạn hãy thử click vào đường link này xem sẽ thấy kết quả trả về rất nhiều đúng không nào. Bạn có thể dùng một vài công cụ để format kết quả đã xem về dạng JSON để dễ xem hơn nhé.

Xem kết quả JSON được format dễ xem hơn
Xem kết quả JSON được format dễ xem hơn

Nào, dựa trên kết quả này, chúng ta biết phải xây dựng lớp data như thế nào. JSON như bạn thấy, trả về là mảng các object, mỗi một object đó có đầy đủ các thông tin của một quốc gia. Có một điều rằng chúng ta không nhất thiết phải xây dựng một lớp sao cho chứa tất cả thông tin của một quốc gia mà JSON trả về, mà chỉ cần những thông tin cần dùng thôi. Như hình minh họa màn hình ứng dụng trên kia, chúng ta chỉ cần thông tin tên quốc gia (chứa ở object name rồi đến field common trong JSON), và tên thủ đô (chứa ở list capital trong JSON).

Để bắt đầu xây dựng các lớp chứa data, chúng ta dùng tới kiểu lớp data (lớp có kiểu data trong Kotlin) như sau. Các lớp chứa data này sẽ nằm trong thư mục model. Chúng ta cần một lớp Name sẽ dùng để chứa object name trong Json. Và cần một lớp Country để chứa một object trong mảng các object ở cấp độ ngoài cùng của JSON.

Nếu bạn nào còn chưa rành lắm về việc tạo mới một lớp Kotlin trong Android Stutio, thì hãy click chuột phải vào thư mục cần tạo lớp, trong trường hợp này là model, rồi chọn New > Kotlin File/Class.

Tạo lớp mới
Tạo lớp mới

Hộp thoại sau xuất hiện, bạn hãy đảm bảo tùy chọn Data Class bên dưới được chọn. Sau đó điền tên lớp là Name ở phần trên của hộp thoại này.

Tạo một lớp mang tên Name
Tạo một lớp mang tên Name

Sau đó trong lớp Name vừa tạo, hãy khai báo nội dung như sau.

data class Name(val common: String)

Bạn thấy bên trong lớp Name này có chứa một thuộc tính common, nó cùng tên với field common bên trong object name của JSON đúng không nào. Trong ví dụ của bài hôm nay bạn nhớ đặt tên các thuộc tính trong các lớp chứa data sao cho giống với tên của field trong JSON nhé. Việc đặt tên giống nhau này nhằm báo cho hệ thống có thể giúp chúng ta gán dữ liệu từ JSON vào lớp data một cách tự động theo biến được khai báo.

Tương tự bạn hãy tạo ra một lớp data nữa có tên Country, nội dung lớp này như sau.

data class Country(val name: Name, val capital: List<String>?)

Tương tự như NameCountry có hai thuộc tính là name và capital đều được đặt tên giống với các field này trong JSON. Ngoài ra bạn có chú ý đến capital có kiểu là List<String>??. Dấu ? cuối cùng của thuộc tính này cho phép capital có thể null. Ồ một quốc gia có thể không có thủ đô ư? Bạn cứ thử lát nữa xem sao nhé.

Vậy thôi, bạn thấy có đơn giản không nào. Cấu trúc project chúng ta đến giai đoạn này như sau.

Kiến trúc Custom đến bước này
Kiến trúc Custom đến bước này

Các Lớp Kết Nối Đến Web Service

Mình xin nhắc lại rằng là chúng ta sẽ dùng thư viện Retrofit để kết nối Web Service. Nên nếu bạn tìm hiểu cách thức sử dụng thư viện này, bạn sẽ thấy có nhiều cách tổ chức các lớp để thực hiện việc kết nối. Ở đây mình sẽ dùng một cách trong số đó, nên nó có thể khác với bạn đôi chút, nhưng chung quy lại vẫn là dựa trên quy tắc của Retrofit mà thôi.

Ngoài Retrofit ra, mình còn dùng RxJava để thông báo kết quả trả về khi kết nối được hoàn thành.

Kịch bản là vậy thôi, mình không nói sâu vào 2 thằng này lắm. Nếu bạn có thắc mắc, hãy tự tìm hiểu 2 thư viện này nhé. Việc của bạn là hãy xây dựng 2 lớp CountriesApi và CountriesService như sau.

Lớp CountriesApi được tạo trong thư mục networking nhé, lớp này chứa định nghĩa các API, đây là một interface xây dựng theo hướng dẫn của Retrofit.

Để tạo một interface thì bạn cứ click chuột phải vào thư mục cần tạo file như bạn đã tạo lớp Country trên kia, chọn New > Kotlin File/Class, đến hộp thoại điền tên lớp bạn nhớ để vệt sáng chọn ở Interface. Hoặc có lỡ thao tác sai chọn lựa thì bạn vẫn có thể thay đổi trong code sau cũng được, yên tâm.

Tạo Interface CountriesApi
Tạo Interface CountriesApi

Vì chúng ta chỉ cần có 1 API nên bạn thấy chúng ta xây dựng 1 phương thức trong interface này. Về sau nếu các bạn muốn xây dựng thêm các API khác để lấy các dữ liệu khác từ Web Service thì cứ add thêm vào nhé.

interface CountriesApi {
 
@GET("all")
fun getCountries(): Single<List<Country>>
}

Bạn hãy tạo tiếp một lớp CountriesService trong thư mục networking này luôn nhé. Đây là lớp chứa đựng các dòng khởi tạo một Retrofit, sẽ được dùng để kết nối đến Web Service sau này.

Có một lưu ý là CountriesService khi này không phải là một lớp bình thường nhé. Lớp này không được khai báo là class như các lớp khác mà là object. Trong Kotlin một lớp được khai báo là object chính là một Singleton. Trong lúc tạo mới lớp bạn hãy chọn sẵn vệt sáng ở Object hoặc có thể chỉnh sửa sau khi tạo lớp bình thường vẫn được.

Tạo Object CountriesService
Tạo Object CountriesService
object CountriesService {
 
private val BASE_URL = "https://restcountries.com/v3.1/"
 
fun create(): CountriesApi {
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build()
 
return retrofit.create(CountriesApi::class.java)
}
}

Đến bước này thì cấu trúc của project chúng ta như sau.

Kiến trúc Custom đến bước này
Kiến trúc Custom đến bước này

Xây Dựng RecyclerView

Về cụ thể RecyclerView như thế nào thì mình sẽ nói trong loạt bài Android cơ bản. Ở đây mình chỉ nêu ra các file được xây dựng cho mục đích của RecyclerView.

Mà ReclyclerView là gì? À nó đơn giản là một dạng UI hiển thị theo kiểu Danh sách lên màn hình í mà. Chúng ta cần UI kiểu này để hiển thị danh sách các quốc gia lên màn hình chính của ứng dụng, thế thôi.

File item_country.xml

Chúng ta tạo một file giao diện cho một phần tử Danh sách. Phần tử này có tên item_country và được tạo trong thư mục res/layout/.

Do mỗi phần tử này sẽ bao gồm tên quốc gia và thủ đô kèm theo, nên giao diện được tạo như sau.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
 
<TextView
android:id="@+id/tvCountry"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Country"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp" />
 
<TextView
android:id="@+id/tvCapital"
app:layout_constraintStart_toStartOf="@+id/tvCountry"
app:layout_constraintTop_toBottomOf="@+id/tvCountry"
tools:text="Capital"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
 
</androidx.constraintlayout.widget.ConstraintLayout>

Trên đây là code XML, còn giao diện thiết kế của Phần tử danh sách như sau.

Giao diện thiết kế phần tử danh sách
Giao diện thiết kế phần tử danh sách

Lớp CountriesAdapter.kt

Kế tiếp bạn hãy tạp mới lớp Adapter của RecyclerView. Lớp này nằm trong thư mục adapter và được đặt tên là CountriesAdapter.

Lớp này nhận truyền vào danh sách các Country, sau đó hiển thị tên quốc gia và thủ đô lên từng phần tử danh sách tương ứng. Adapter này còn kiêm nhiệm việc nhận sự kiện click lên mỗi phần tử danh sách và trả về nội dung của phần tử được click ra ngoài thông qua một interface tự định nghĩa nữa. Một điểm nữa ở Adapter này, đó là nó đã sử dụng View Binding để kết nối tới các view id bên XML.

class CountriesAdapter(val countries: ArrayList<Country>) :
RecyclerView.Adapter<CountriesAdapter.CountryViewHolder>() {
 
var listener: OnItemClickListener? = null
 
fun updateCountries(newCountries: List<Country>) {
countries.clear()
countries.addAll(newCountries)
notifyDataSetChanged()
}
 
fun setOnItemClickListener(listener: OnItemClickListener) {
this.listener = listener
}
 
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = CountryViewHolder(
ItemCountryBinding.inflate(LayoutInflater.from(parent.context))
)
 
override fun getItemCount() = countries.size
 
override fun onBindViewHolder(holder: CountryViewHolder, position: Int) {
holder.bind(countries[position], listener)
}
 
class CountryViewHolder(private val binding: ItemCountryBinding) :
RecyclerView.ViewHolder(binding.root) {
 
fun bind(country: Country, listener: OnItemClickListener?) {
binding.apply {
tvCountry.text = country.name.common
tvCapital.text = country.capital?.joinToString(", ")
root.setOnClickListener { listener?.onItemClick(country) }
}
}
}
 
interface OnItemClickListener {
fun onItemClick(country: Country)
}
}

Và cấu trúc của project chúng ta đến giai đoạn này như sau.

Kiến trúc Custom đến bước này
Kiến trúc Custom đến bước này

Hoàn Thiện MainActivity

Chà chà xây dựng một project trong Android quả là khá dài. Dù sao thì chúng ta cũng đã đến những bước cuối cùng rồi.

Chỉnh sửa activity_main.xml

Trước hết chúng ta cần chỉnh sửa lại activity_main sao cho hiển thị giao diện tìm kiếm phía trên, rồi danh sách các quốc gia (chính là RecyclerView) ở phần không gian còn lại. Ngoài ra cũng cần có một loading bar xuất hiện khi danh sách đang được tải về.

Code giao diện sẽ như sau.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#dfdfdf"
tools:context=".activity.MainActivity">
 
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/listView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:background="#ffffff"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/searchField"
tools:visibility="visible">
 
</androidx.recyclerview.widget.RecyclerView>
 
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
 
<EditText
android:id="@+id/searchField"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:ems="10"
android:hint="Search"
android:inputType="textPersonName"
android:minHeight="48dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
 
</androidx.constraintlayout.widget.ConstraintLayout>

Còn đây là màn hình thiết kế giao diện main_activity.

Giao diện thiết kế màn hình chính
Giao diện thiết kế màn hình chính

Chỉnh Sửa MainActivity.kt

Nào, với Kiến trúc custom, mình sẽ để MainActivity chịu trách nhiệm gọi thực hiện các kết nối đến Web ServiceMainActivity cũng chịu trách nhiệm lắng nghe kết quả trả về, và điều khiển UI để hiển thị kết quả hoặc thông báo lỗi.

Chúng ta đi từng bước để bạn dễ nắm. Đây là code nguyên thủy của MainActivity khi bạn mới tạo project.

class MainActivity : AppCompatActivity() {
 
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}

Đầu tiên chúng ta phải khai báo View Binding cho Activity này. Việc chuyển sang sử dụng View Binding sẽ giúp các lớp tương tác được đến các view id dễ dàng hơn (như ở bước thiết kế Adapter trên kia bạn đã làm quen), mà không cần phải gọi thông qua findViewById() dài dòng hay Kotlin Synthetic đã chính thức bị khai tử. Với việc sử dụng View Binding thì MainActivity sẽ trông như sau.

class MainActivity : AppCompatActivity() {
 
private lateinit var binding: ActivityMainBinding
 
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
}
}

Tiếp theo hãy xây dựng việc gọi đến Web Service bằng các khai báo được tô sáng sau.

class MainActivity : AppCompatActivity() {
 
private lateinit var binding: ActivityMainBinding
private var apiService: CountriesApi? = null
 
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
 
apiService = CountriesService.create()
 
onFetchCountries()
}
 
fun onFetchCountries() {
apiService?.let {
it.getCountries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
// Lấy về thành công danh sách quốc gia
// Làm gì đó sau
}, { error ->
onError()
})
}
}
 
fun onError() {
Toast.makeText(this, "Error", Toast.LENGTH_SHORT).show()
}
}

Code không quá khó hiểu đúng không bạn. Cơ bản ở onCreate() sẽ lo việc khởi tạo CountriesService. Rồi gọi onFetchCountries() để kết nối tới Web Service lấy về danh sách các quốc gia. Nếu kết quả trả về thành công thì chúng ta code sau. Còn trả về thất bại thì onError() sẽ thụ lý hiển thị một thông báo dạng Toast.

Tiếp theo, chúng ta xây dựng đến logic của loading. Bạn chú ý tới những dòng code mới thêm vào được tô sáng thôi nhé.

class MainActivity : AppCompatActivity() {
 
private lateinit var binding: ActivityMainBinding
private var apiService: CountriesApi? = null
 
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
 
apiService = CountriesService.create()
 
onFetchCountries()
}
 
fun onFetchCountries() {
binding.progress.visibility = View.VISIBLE
 
apiService?.let {
it.getCountries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
binding.progress.visibility = View.GONE
// Lấy về thành công danh sách quốc gia
// Làm gì đó sau
}, { error ->
onError()
})
}
}
 
fun onError() {
binding.progress.visibility = View.GONE
 
Toast.makeText(this, "Error", Toast.LENGTH_SHORT).show()
}
}

Về logic của kết quả trả về thành công và hiển thị lên RecyclerView, có các dòng code được tô sáng sau. À chúng ta cũng xây dựng sự kiện click trên phần tử của danh sách này luôn nhé.

class MainActivity : AppCompatActivity() {
 
private lateinit var binding: ActivityMainBinding
private var apiService: CountriesApi? = null
private val countriesAdapter = CountriesAdapter(arrayListOf())
 
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
 
apiService = CountriesService.create()
 
binding.listView?.apply {
layoutManager = LinearLayoutManager(context)
adapter = countriesAdapter
}
countriesAdapter.setOnItemClickListener(object : CountriesAdapter.OnItemClickListener {
override fun onItemClick(country: Country) {
Toast.makeText(this@MainActivity, "Country ${country.name}, capital is ${country.capital} clicked", Toast.LENGTH_SHORT).show()
}
})
 
onFetchCountries()
}
 
fun onFetchCountries() {
binding.listView.visibility = View.GONE
binding.progress.visibility = View.VISIBLE
 
apiService?.let {
it.getCountries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
binding.listView.visibility = View.VISIBLE
binding.progress.visibility = View.GONE
 
countriesAdapter.updateCountries(result)
}, { error ->
onError()
})
}
}
 
fun onError() {
binding.listView.visibility = View.GONE
binding.progress.visibility = View.GONE
 
Toast.makeText(this, "Error", Toast.LENGTH_SHORT).show()
}
}

Cuối cùng chúng ta sẽ xây dựng chức năng tìm kiếm trên danh sách quốc gia ở các dòng code được tô sáng.

class MainActivity : AppCompatActivity() {
 
private lateinit var binding: ActivityMainBinding
private var apiService: CountriesApi? = null
private val countriesAdapter = CountriesAdapter(arrayListOf())
private var countries: List<Country>? = null
 
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
 
apiService = CountriesService.create()
 
binding.listView?.apply {
layoutManager = LinearLayoutManager(context)
adapter = countriesAdapter
}
countriesAdapter.setOnItemClickListener(object : CountriesAdapter.OnItemClickListener {
override fun onItemClick(country: Country) {
Toast.makeText(this@MainActivity, "Country ${country.name}, capital is ${country.capital} clicked", Toast.LENGTH_SHORT).show()
}
})
 
binding.searchField.addTextChangedListener(object : TextWatcher {
 
override fun afterTextChanged(s: Editable) {
if (s.isNotEmpty()) {
val filterCountries = countries?.filter { country ->
country.name.common.contains(s.toString(), true)
}
filterCountries?.let { countriesAdapter.updateCountries(it) }
} else {
countries?.let { countriesAdapter.updateCountries(it) }
}
}
 
override fun beforeTextChanged(
s: CharSequence, start: Int,
count: Int, after: Int
) {
}
 
override fun onTextChanged(
s: CharSequence, start: Int,
before: Int, count: Int
) {
}
})
 
onFetchCountries()
}
 
fun onFetchCountries() {
binding.listView.visibility = View.GONE
binding.progress.visibility = View.VISIBLE
binding.searchField.isEnabled = false
 
apiService?.let {
it.getCountries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
binding.listView.visibility = View.VISIBLE
binding.progress.visibility = View.GONE
binding.searchField.isEnabled = true
 
countries = result
countriesAdapter.updateCountries(result)
}, { error ->
onError()
})
}
}
 
fun onError() {
binding.listView.visibility = View.GONE
binding.progress.visibility = View.GONE
binding.searchField.isEnabled = false
 
Toast.makeText(this, "Error", Toast.LENGTH_SHORT).show()
}
}

Xong rồi, giờ bạn có thể thực thi chương trình để xem thành quả của bài hôm nay rồi đó.

Kết Luận

Qua bài viết hôm nay thì bạn có thể thấy tổng quan cách thức xây dựng một ứng dụng Android theo kinh nghiệm của bản thân mình (dĩ nhiên có kèm tìm hiểu và thống nhất với nhau của cả nhóm làm việc chung). Về cơ bản thì cách thức xây dựng project theo Kiến trúc custom này không hề dở, bạn vẫn có thể theo nó đến suốt cuộc đời của một project mà không cần phải quá khổ sở trong việc quản lý nó.

Nhưng bạn có thể dễ nhận ra, MainActivity hay các lớp liên quan đến giao diện sẽ ngày một phình ra theo độ lớn của project, đến nỗi một ngày nào đó bạn sẽ gần như mất quyền kiểm soát logic của các màn hình này. Mất kiểm soát ở đây ví dụ như tình huống ngày càng có nhiều code dư thừa mà bạn không hiểu hoặc không biết làm sao dọn dẹp, khiến chúng ảnh hưởng đến hiệu năng của sản phẩm. Hoặc sẽ rất khó khăn khi tách Activity thành các Fragment chẳng hạn. Chưa kể đến các lỗi crash ứng dụng nhức đầu liên quan đến leak memory nữa. Và một vấn đề nữa đó là chúng ta sẽ rất khó xây dựng các Unit Test cho project nếu tất cả đều đổ dồn vào các lớp giao diện như thế này. Chúng ta cần chia nhỏ nhiệm vụ ra, giao diện là giao diện, xử lý là xử lý, lưu trữ là lưu trữ. Chính vì vậy mà MVCMVP và MVVM ra đời ở các bài học tiếp theo.

Modern Android Architectures – MVC/MVP/MVVM – Phần 2: Kiến Trúc MVC

Ở phần trước chúng ta đã cùng nhau tìm hiểu về tổng quan các mô hình Kiến trúc trong lập trình Android rồi. Dựa trên những điều sơ khởi đó chúng ta còn cùng nhau xây dựng một ứng dụng thực tế, được xây dựng dựa trên một Kiến trúc custom, tức là theo cách tổ chức lớp lang theo kinh nghiệm của bản thân.

Do project sơ khởi của chúng ta khá nhỏ, số lượng lớp rất ít, nên bạn cũng sẽ khó hình dung được tại sao phải cần đến một Kiến trúc chuẩn nào đó thay vì custom. Bạn chỉ có thể tưởng tượng rằng tương lai ứng dụng của bạn sẽ có thể lên tới vài chục màn hình, và do đó số lượng các Activity hay Fragment cũng sẽ nhiều tương ứng. Rồi từ đó các lớp liên quan đến logic của ứng dụng cũng xuất hiện ngày càng nhiều. Có thể lên đến hàng trăm lớp trong ứng dụng của bạn. Mà chưa kể mỗi lớp như vậy có thể lên tới hàng ngàn dòng code. Thì khi đó việc tìm kiếm, chỉnh sửa, cũng như thêm mới các dòng code hoặc các lớp sẽ trở nên đau đầu đến nhường nào.

Vậy nên chúng ta sẽ rất cần tìm hiểu các cách thức chung nhất, các quy tắc rõ ràng cho chính bản thân chúng ta, và cho những thành viên khác trong team, cùng hướng tới, để tạo nên một sự nhất quán trong project. Hôm nay bạn thể hiện rõ ràng kiến trúc của ứng dụng với một màn hình hiển thị danh sách các quốc gia. Thì hôm sau bạn hoặc đồng nghiệp cũng sẽ dễ dàng thêm vào màn hình, API, cũng như logic cho chức năng tiếp theo của ứng dụng. Ví dụ như, hiển thị thông tin đầy đủ của từng quốc gia, bao gồm cả thể hiện biểu đồ chẳng hạn. Và hôm sau nữa ứng dụng lại có thêm chức năng hiển thị bản đồ thế giới, chức năng hiển thị Maps,… Các chức năng được thêm vào theo một nguyên tắc, một trật tự ngăn nắp theo ý bạn. Để làm được vậy mời bạn cùng bắt đầu với MVC.

Giới Thiệu Về Mô Hình MVC

Theo như mình biết, thì MVC sinh ra không phải dành cho phát triển phần mềm AndroidMVC có trước khi Android xuất hiện, khi đó MVC được tạo ra để giải quyết các vấn đề về kiến trúc cho lập trình Web. Sau đó MVC được ứng dụng trên các ứng dụng desktop khác.

Sau này khi công nghệ lập trình Android phát triển, MVC được các lập trình viên đưa vào Android để thử nghiệm. Dù cho MVC khi đó có thể không hoàn toàn phù hợp với Android, nhưng nó đã mở đầu và đã trở thành nền móng cho các sáng kiến, để sau đó, các mô hình kiến trúc phù hợp hơn với Android sau này ra đời, như MVP hay MVVM mà chúng ta sẽ nói đến ở bài tiếp theo.

Quy Tắc MVC

MVC là viết tắt của 3 chữ: Model – View – Controller.

3 chữ này đại diện cho 3 layer. Các lập trình viên sẽ phải hiểu nguyên tắc của từng layer, để mà tạo ra các lớp tương ứng với các layer đó. Lớp nào thuộc layer nào sẽ tuân thủ theo nguyên tắc của layer đó. Việc đặt tên lớp hay package/directory có thể bao hàm cả tên layer để dễ hiểu và dễ quản lý. Dưới đây là chức năng cụ thể của từng layer.

  • Model: Layer này sẽ chứa các lớp liên quan đến lưu trữ dữ liệu, hay đảm nhiệm xử lý các nghiệp vụ logic của ứng dụng. Bạn tưởng tượng model giống như bộ não của con người, nó giúp xử lý và lưu trữ dữ liệu.
  • View: layer này sẽ chứa các lớp liên quan đến hiển thị, và nhận tương tác từ phía người dùng. Nếu tưởng tượng đến các cơ quan của con người, thì view chính là các giác quan, giúp “nghe”“ngửi”“nếm”“nhìn”“cảm giác” từ bên ngoài rồi chuyển vào cho model xử lý. Đồng thời có thể “nói” ra môi trường bên ngoài sau khi nhận kết quả xử lý từ model. Thực sự thì view nên trung lập nhất có thể, nó chỉ có thể nhận dữ liệu vào, và hiển thị dữ liệu ra, nó không có “cảm xúc” hay xử lý logic gì cả. Tất cả những gì nó cần làm là lấy dữ liệu từ người dùng rồi gọi đến controller hoặc model để thực hiện tiếp các tác vụ. Rồi sau đó view cũng sẽ hiển thị kết quả sau khi nhận được xử lý từ controller.
  • Controller: Layer này chứa các lớp đảm nhận vai trò là cầu nối giữa view và model. Những tương tác của người dùng từ view sẽ được controller chuyển đến model. Và ngược lại, những thay đổi từ model sẽ được controller cập nhật lên view. Như vậy controller giống như các liên kết giúp dẫn truyền “cảm giác” đến não và ngược lại.

Bạn đã bắt đầu thấy sự rõ ràng trong việc phân tách các nhiệm vụ của các lớp trong Android vào các nhóm chức năng chưa nào. Bắt đầu thú vị rồi đúng không. Để minh họa cho các layer trên, người ta vẽ ra một sơ đồ cho MVC như sau.

Sơ đồ kiến trúc MVC
Sơ đồ kiến trúc MVC

Chúng ta hãy dành để nói đến các ưu điểm và khuyết điểm của mô hình MVC này ở cuối bài, khi mà tất cả đều rõ ràng MVC là gì sau khi áp dụng vào thực tế xây dựng ứng dụng Android ở phần kế tiếp.

MVC Trong Android

Tại sao lại nói thêm ý ở mục này? Vì như mình có nói, MVC thực chất sinh ra không phải cho riêng lập trình Android. Mà chính các lập trình viên Android lúc bấy giờ mong muốn áp dụng MVC vào Android để biến việc lập trình này trở nên đúng chuẩn với những gì mà cộng đồng lập trình viên khác đang dùng.

Chính vì vậy nên việc áp dụng MVC vào Android rất khác với các nền tảng lập trình Web cũng như desktop. Một câu hỏi rất lớn, và gây nhiều tranh cãi trong việc áp dụng này, là Activity, Fragment, hay các lớp liên quan đến View khác sẽ nằm trong layer nào theo mô hình MVC? Trong quá trình tìm hiểu mình nhận ra có 2 trường phái lớn trong nhận định này.

Có trường phái cho rằng Activity, Fragment hay các lớp liên quan đến View trong Android vừa thuộc layer view vừa đảm nhiệm luôn vai trò controller. Chính vì vậy mà các lớp này vừa chứa đựng các chức năng hiển thị UI, tương tác với người dùng, và làm các thao tác kết nối với model. Thực sự thì mình nhận thấy sở dĩ có trường phái này là bởi vì nó hoàn toàn giống với cách tổ chức theo Kiến trúc custom mà chúng ta đã thử làm quen ở bài trước, và vì vậy mà lập trình viên Android khi này không cần phải chỉnh sửa project quá nhiều để có được một MVC. Chỉ cần tách một số xử lý liên quan đến lưu trữ, hay logic của ứng dụng vào Model là được.

Trường phái còn lại thì khắt khe hơn khi giới hạn Activity, Fragment hay các lớp liên quan đến View trong Android vào trọng trách của layer view, tức là chỉ nhận tương tác từ người dùng, và phản hồi lại người dùng, thế thôi. Model vẫn như trường phái trên. Controller thì tách ra thành các lớp riêng, lo việc kết nối giữa view với model. Việc tổ chức theo trường phái này theo mình là rõ ràng hơn, và có thể dễ dàng xây dựng các Unit Test cho từng layer một cách dễ dàng hơn. Và phần xây dựng ứng dụng bên dưới mình sẽ hướng theo trường phái này.

Ngoài ra còn có các trường phái nhỏ khác như view chính là các file XML. Activity, Fragment, các lớp View khác là controller. Rồi model thì có model bị động (chỉ trả dữ liệu về cho view khi được yêu cầu) hay model chủ động (tự động gọi cập nhật dữ liệu trên view dựa vào observer). Tuy nhiên mình không nói quá nhiều về các kiểu MVC Android này. Mình chỉ kể ra cho các bạn đủ thấy rằng áp dụng MVC vào Android vẫn là một cuộc chiến, và mỗi người, mỗi team sẽ có một MVC riêng phù hợp với nhu cầu của họ hơn. Rất khó để đưa ra kết luận MVC Android nào là đúng chuẩn MVC nhất.

Bắt Tay Xây Dựng Ứng Dụng

Dù MVC còn khá nhiều cách áp dụng khác nhau như mình có nói trên đây, thì mình vẫn sẽ chọn ra một cách thức phù hợp nhất để chúng ta cùng nhau xây dựng để có thể hiểu rõ hơn về mô hình Kiến trúc này.

Nếu bạn chưa xây dựng ứng dụng mẫu từ bài trước, thì mình khuyên bạn nên bắt tay vào xây dựng nhanh. Hoặc bạn có thể lấy nhanh source code từ Github của bài hôm trước theo link này. Dựa trên ứng dụng của bài trước, chúng ta sẽ chỉnh sửa lại, hay kỹ thuật gọi là refactor code, để trở thành kiến trúc MVC của bài hôm nay.

Nào giờ hãy mở project ModernAndroidArchitectures ra.

Tổng Quan Project

Cái này mình giới thiệu lại project, đã được nói tới ở mục này của bài trước rồi.

Project của tất cả bài viết trong chủ đề Modern Android Architectures này đều có chung một kết quả màn hình như sau.

Màn hình ứng dụng mẫu
Màn hình ứng dụng mẫu

Ứng dụng sẽ kết nối đến Web Service để lấy về danh sách các quốc gia kèm thủ đô của nó. Web Service này được xây dựng sẵn trên trang restcountries.com. Ngoài việc hiển thị danh sách các quốc gia, ứng dụng còn có chức năng tìm kiếm theo tên quốc gia. Khi click vào bất kỳ quốc gia nào trên danh sách sẽ hiển thị một message dạng Toast.

Tổng Quan MVC Trong Project Này

Trước hết hãy cùng xem lại các lớp và cách tổ chức chúng vào các package của bài trước sẽ như thế này.

Kiến trúc Custom của bài trước
Kiến trúc Custom của bài trước

Với mục đích tìm hiểu về MVC, thì để dễ nhìn nhất, chúng ta có thể tạo ra các package với các tên gọi tương ứng, là viewmodel, và controller. Rồi để các lớp tương ứng vào. Tuy nhiên thì trong thực tế bạn có thể giữ nguyên cấu trúc package như với bài trước, chỉ cần tách các chức năng ra từng lớp cụ thể theo đúng vai trò của chúng là được.

Như vậy để tách các package theo viewmodel và controller, thì các lớp tương ứng trong project của chúng ta sẽ nằm trong từng package như mô tả sau.

Các lớp trong project tương ứng với MVC
Các lớp trong project tương ứng với MVC

Bạn lưu ý là MainActivity ở bài trước sẽ được chuyển thành CountriesActivity ở bài này, để cho cái tên của nó được nhất quán (vì màn hình này là hiển thị các danh sách các quốc gia, nên các lớp phục vụ cho hiển thị, cũng như logic của màn hình này đều bắt đầu bằng cái tên Country hoặc Countries). Chúng ta sẽ bắt tay vào thay đổi MainActivity thành CountriesActvity ở mục bên dưới sau nhé.

Nhưng như bạn cũng thấy đó, câu hỏi đặt ra là các lớp liên quan đến kết nối Web Service, như CountriesApi, hay CountriesService, chúng đâu rồi.

Thực ra thì khi xây dựng các kiến trúc này, bạn cũng đừng cứng nhắc quá. Có một số lớp hay package vốn dĩ nó không thuộc về viewmodel hay controller. Như các lớp kết nối với Web Service trên đây. Vì chúng không làm nhiệm vụ hiển thị UI, hay tương tác với người dùng, hay lưu trữ dữ liệu, hay xử lý nghiệp vụ logic, hay cầu nối giữa view và model gì cả. Chúng “độc lập” về chức năng với viewmodel, và controller. Hay sau này bạn có thêm các lớp khác, như UtilConfigConstant,… chẳng hạn. Thì các lớp này vẫn nên được tổ chức vào các package riêng khác, như package networking với project này, hoặc util gì đó cho các project khác của bạn. Bạn đã hiểu chưa nào.

Nói như vậy có nghĩa là package networking và các lớp bên trong package này mình sẽ giữ nguyên như project xây dựng ở bài trước.

Xây Dựng Layer Model

Bạn có nhớ là bài trước chúng ta xây dựng các lớp Country và Name trong package model rồi đúng không nào. Theo như mô hình MVC thì lớp thuộc layer model sẽ chịu trách nhiệm chính trong việc quản lý dữ liệu, quản lý nghiệp vụ logic của ứng dụng.

Nhưng với project ví dụ này thì ứng dụng của chúng ta chẳng có mấy logic gì cả, trừ một điều là khi nhấn vào một item trên danh sách các quốc gia ở CountriesActivity thì một message dạng Toast hiện ra. Với bài trước thì message này khá đơn giản nên mình cho Activity tự hiển thị. Nhưng với bài học hôm nay, mình giao cho Country và Name một trọng trách “nặng nề” hơn, đó là lo luôn nội dung của message, mình xem tác vụ soạn nội dung message này là một nghiệp vụ logic của ứng dụng. Sau này các bạn có các nghiệp vụ phức tạp hơn, như xử lý thông tin về dân số, diện tích, đưa ra các nhận định về phát triển dân số, hay các phương thức lưu trữ thông tin quốc gia vào máy chẳng hạn, thì cứ để model Country và Name làm cho nhé.

Cũng có một lưu ý rằng lớp Country ở bài học trước sẽ được mình đổi tên thành CountryModel , còn Name sẽ đổi thành NameModel ở bài hôm nay như quy luật đặt tên mình nói đến trên kia.

Để thay đổi tên mộp lớp thì bạn hãy click chuột phải vào lớp đó trong cửa sổ Project, rồi chọn Refactor > Rename….

Sửa tên lớp Country
Sửa tên lớp Country

Cửa sổ tiếp theo xuất hiện thì bạn hãy điền vào tên lớp mới, rồi nhấn Refactor. Tiếp đó nếu có popup nào hiện ra thì bạn cứ nhấn OK nữa thôi.

Sửa tên lớp thành CountryModel
Sửa tên lớp thành CountryModel

Bạn hãy làm tương tự để đổi tên lớp Name thành NameModel nhé.

Và code của CountryModel như sau. Bạn thấy có thêm một phương thức lo logic cho ứng dụng được mình tô sáng lên.

data class CountryModel(val name: NameModel, val capital: List<String>?) {
fun getCountryInfo() = "Country ${name.common}, capital is ${capital?.joinToString(", ")} - clicked"
}

Xây Dựng Layer Controller

Đến đây thì bạn phải tạo mới một package có tên controller vì bài hôm trước chưa có package này. Hình ảnh sau khi tạo thêm package controller như sau.

Kiến trúc project khi thêm package controller
Kiến trúc project khi thêm package controller

Sau đó hãy tạo mới một lớp có tên CountriesController trong package controller này.

Bạn cũng đã biết, trong mô hình MVC, lớp thuộc layer controller sẽ làm nhiệm vụ kết nối giữa view và model và một số kết nối đến các package khác nếu cần. Việc kết nối này cũng nhằm giúp giảm tải một số xử lý từ view. Trong project của chúng ta hôm trước thì các phương thức kết nối đến Web Service chính là vai trò mà CountriesController sẽ đảm nhận trong bài hôm nay. Bạn có thể xây dựng thêm các kết nối sau này trong CountriesController, như gọi đến CountryModel để lưu dữ liệu, rồi còn lấy dữ liệu cho CountriesActivity,…

Vậy bạn hãy mang các khai báo và các xử lý liên quan đến Web Service vào bên trong CountriesController này như sau.

class CountriesController(private val apiService: CountriesApi) {
fun onFetchCountries() {
apiService.let {
it.getCountries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
// Trả về kết quả thành công, sẽ làm sau
}, { error ->
// Trả về kết quả thất bại, sẽ làm sau
})
}
}
}

Bạn có thể so sánh một chút với MainActivity đã xây dựng ở bài trước để thấy việc giảm tải bằng cách mang onFetchCountries() vào trong CountriesController này. Việc làm này của CountriesController giúp lớp này và MainActivity có quan hệ với nhau. MainActivity là một view sẽ không còn phải lo lấy dữ liệu nữa, mà MainActivity sẽ “nhờ” CountriesController giúp. Thực ra thì CountriesController cũng chỉ làm nhiệm vụ giao tiếp, giúp khai báo và gọi tới CountriesApi để thực hiện việc lấy dữ liệu các quốc gia về. Rồi CountriesController cũng sẽ gọi đến CountryModel để lớp này lo xử lý dữ liệu hoặc lưu trữ nếu có mà thôi. Bạn đã thấy vai trò cầu nối của ContriesController rồi đúng không nào.

Và kiến trúc của project đến bước này như sau.

Kiến trúc của project đến giai đoạn này
Kiến trúc của project đến giai đoạn này

Xây Dựng Layer View

Như mình đã nói trên kia, mình đi theo hướng view trong mô hình MVC cho Android sẽ chứa các lớp liên quan đến giao diện, như Activity, Fragment, Dialog hay các View khác.

Do đó package view của mình sẽ chứa các lớp như dưới đây (lưu ý là bạn có thể xây dựng package view, rồi trong đó phân cấp ra thành các package con khác như activityfragmentdialogadapter,… khi project đã quá lớn rồi nhé).

À, thực ra chúng ta không cần tạo mới một package nào cả, mình sẽ đổi package activity của bài trước thành view của bài hôm nay. Để làm như vậy thì tương tự như khi bạn đổi tên lớp, bạn hãy click chuột phải vào package activity rồi chọn Refactor > Rename….

Đổi tên package activity thành view
Đổi tên package activity thành view

Cũng tương tự như việc bạn đổi tên lớp, popup tiếp theo xuất hiện bạn hãy gõ view.

Đổi tên package activity thành package view
Đổi tên package activity thành package view

Sau đó hãy đổi MainActivity thành CountriesActivity như chúng ta đã nói đến trên kia nhé.

Còn CountriesAdapter? Như mình cũng có nói, lớp này thuộc layer view. OK, kéo lớp này vào view. Sau đó xóa package adapter bằng cách click chuột phải vào package này và chọn Delete.

Xóa package adapter
Xóa package adapter

Và cấu trúc của project chúng ta sẽ như sau.

Cấu trúc project đến bước này
Cấu trúc project đến bước này

Việc tiếp theo cần làm là chỉnh sửa lại CountriesActivity bằng cách xóa các dòng code liên quan đến việc tự khai báo và kết nối API, rồi gọi đến CountriesController để nhờ lớp này lo phần này.

Lớp CountriesActivity sẽ được sửa như sau. Mình sẽ tô sáng vài chỗ quan trọng, bạn nhìn vào sẽ hiểu ngay thôi. Mình sẽ giải thích một tí bên dưới.

class CountriesActivity : AppCompatActivity() {
 
private lateinit var binding: ActivityMainBinding
private lateinit var countriesController: CountriesController
private lateinit var apiService: CountriesApi
private val countriesAdapter = CountriesAdapter(arrayListOf())
private var countries: List<CountryModel> = listOf()
 
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
 
apiService = CountriesService.create()
countriesController = CountriesController(apiService)
 
binding.listView?.apply {
layoutManager = LinearLayoutManager(context)
adapter = countriesAdapter
}
countriesAdapter.setOnItemClickListener(object : CountriesAdapter.OnItemClickListener {
override fun onItemClick(country: CountryModel) {
Toast.makeText(this@CountriesActivity, "Country ${country.name}, capital is ${country.capital} clicked", Toast.LENGTH_SHORT).show()
}
})
 
binding.searchField.addTextChangedListener(object : TextWatcher {
 
override fun afterTextChanged(s: Editable) {
if (s.isNotEmpty()) {
val filterCountries = countries?.filter { country ->
country.name.common.contains(s.toString(), true)
}
filterCountries?.let { countriesAdapter.updateCountries(it) }
} else {
countries?.let { countriesAdapter.updateCountries(it) }
}
}
 
override fun beforeTextChanged(
s: CharSequence, start: Int,
count: Int, after: Int
) {
}
 
override fun onTextChanged(
s: CharSequence, start: Int,
before: Int, count: Int
) {
}
})
 
onFetchCountries()
}
 
fun onFetchCountries() {
binding.listView.visibility = View.GONE
binding.progress.visibility = View.VISIBLE
binding.searchField.isEnabled = false
 
countriesController.onFetchCountries()
}
 
fun onSuccessful(result: List<CountryModel>) {
binding.listView.visibility = View.VISIBLE
binding.progress.visibility = View.GONE
binding.searchField.isEnabled = true
 
countries = result
countriesAdapter.updateCountries(countries)
}
 
fun onError() {
binding.listView.visibility = View.GONE
binding.progress.visibility = View.GONE
binding.searchField.isEnabled = false
 
Toast.makeText(this, "Error", Toast.LENGTH_SHORT).show()
}
}

Đầu tiên bạn có thể thấy mình thêm khai báo sử dụng CountriesController. Một lát nữa CountriesController sẽ lo nhiệm vụ kết nối tới Web Service như mình đã nói.

CountriesController được khởi tạo trong phương thức onCreate() ở dòng tô sáng tiếp theo. Chúng ta truyền CountriesApi cũng vừa được khởi tạo vào trong CountriesController này. Việc CountriesActivity quản lý luôn việc khởi tạo CountriesApi rồi mới truyền vào cho CountriesController giúp cho CountriesController bớt phụ thuộc vào CountriesApi hơn. Kỹ thuật này được gọi là Dependency Injection.

Ở onFetchCountries() lúc này CountriesActivity chỉ việc “nhờ” CountriesController làm việc kết nối đến Web Service dùm. Thế thôi, cho code của Activity được ngắn bớt.

À mà bởi vì nhờ CountriesController lo việc gọi API, nên CountriesActivity nên làm thêm phương thức onSuccessful(), phương thức này chứa các code liên quan đến lời gọi thành công từ Web Service, chúng ta tách nó ra để CountriesController gọi về sau đó mà thôi.

Nhưng khoan, project vẫn còn thiếu cái gì đó. Đúng rồi, chúng ta chưa thực sự xây dựng hoàn chỉnh sự kết nối từ CountriesActivity và CountriesController. Cụ thể khi mà CountriesController gọi Web Service thành công, nó sẽ update dữ liệu này đến CountriesActivity thế nào? Để trả lời cầu hỏi này thì mình mời bạn mở lại CountriesController để cùng thêm vào một số thay đổi sau.

class CountriesController(
private var view: CountriesActivity,
private val apiService: CountriesApi
) {
fun onFetchCountries() {
apiService.let {
it.getCountries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
view.onSuccessful(result)
}, { error ->
view.onError()
})
}
}
}

Sự thay đổi này có nghĩa chúng ta sẽ truyền CountriesActivity vào trong CountriesController luôn. Sau khi kết thúc việc gọi API, CountriesController sẽ gọi về lại cho CountriesActivity cập nhật lại UI thông qua các lời gọi view.onSuccessful() và view.onError().

Sự thay đổi này có nhiều điều để nói. Mình muốn nói thêm cho bạn hiểu hơn về việc tổ chức này của MVC ở bài học hôm nay.

  • Thứ nhất, lỡ đâu chúng ta muốn dùng chung một CountriesController cho nhiều view khác nhau chứ không riêng gì CountriesActivity, thì việc truyền vào này sẽ gây nên lỗi biên dịch, CountriesController không chịu chấp nhận bất kỳ lớp nào khác ngoài CountriesAcvitiy. Để giải quyết vấn đề này chúng ta có thể dùng đến một lớp Activity cha. Rồi CountriesActivity hay bất kỳ Activity nào dùng chung CountriesController cũng phải kế thừa Activity cha này. Sau đó ở tham số truyền vào hàm khởi tạo của CountriesController chúng ta chỉ truyền lớp Activity cha thôi, khi gọi kết quả trả về CountriesController sẽ biết Activity con nào gọi đến để mà trả kết quả về cho đúng Activity con đó.
  • Thứ hai, bạn lo lắng về việc lỗi liên quan đến leak memory. Chẳng hạn khi bạn truyền view vào cho controller xong rồi gọi kết nối API, trước khi controller nhận kết quả trả về thì view bị hệ thống hủy bỏ (người dùng tắt ứng dụng, hoặc chuyển sang view khác), thì việc gọi trả kết quả về sau đó sẽ gây lỗi. Bạn đã lo lắng đúng. Để giải quyết vấn đề này bạn có thể check view khác null trước khi trả về, hoặc có thể tạo Interface và truyền vào controller như một listener lắng nghe kết quả trả về thay vì truyền vào đó là một view. Chà lung tung nhỉ nhưng mình nghĩ là bạn hiểu.
  • Thứ ba, đây là một nâng cấp chứ không phải lo lắng. Đó là thay vì truyền vào controller là một view, bạn có thể xây dựng các cấu trúc chủ động hơn trong việc cập nhật dữ liệu cho view sau khi dữ liệu đã được gọi thành công, bằng Observer chẳng hạn. Sẽ tuyệt hơn nhiều đấy.

Dù sao thì chúng ta cũng đã đến gần đích lắm rồi, hãy thay đổi hai chỗ ở CountriesActivity, một là truyền thêm this vào CountriesController. Hai là gọi CountryModel nhờ “soạn” giúp câu chữ in ra vì CountryModel “am hiểu” rất rõ dữ liệu và nghiệp vụ logic của nó. Xong rồi thực thi ứng dụng nào.

 
class CountriesActivity : AppCompatActivity() {
 
// Code chỗ này không quan tâm, mình ẩn đi
 
override fun onCreate(savedInstanceState: Bundle?) {
// Code chỗ này không quan tâm, mình ẩn đi
 
apiService = CountriesService.create()
countriesController = CountriesController(this, apiService)
 
// Code chỗ này không quan tâm, mình ẩn đi
 
countriesAdapter.setOnItemClickListener(object : CountriesAdapter.OnItemClickListener {
override fun onItemClick(country: CountryModel) {
Toast.makeText(this@CountriesActivity, country.getCountryInfo(), Toast.LENGTH_SHORT).show()
}
})
 
// Code chỗ này không quan tâm, mình ẩn đi
}
 
// Code chỗ này không quan tâm, mình ẩn đi
}
 

Cám ơn bạn đã đọc tài liệu của chúng tôi

Công ty cổ phần thương mại Vạn Tín Việt

0936.006.058
0936.006.058