Site icon Yellow Code Books

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

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

Colorful jigsaw puzzle pieces concept isolated on white background with shadow 3D rendering

Advertisements

Chào mừng các bạn đã đến với loạt bài viết về chủ đề Modern Android Architectures.

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: MVCMVP. 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 MVC. MVP 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 MVPMVC đề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ữ: ModelViewPresenter.

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.

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

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 MVCMVP 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ữ CP. Nhưng chung quy lại controllerpresenter lại có cùng một mục đích, đó là cầu nối giữa viewmodel. 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 viewmodel 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 viewmodel 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 view. Presenter 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

Ứ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

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 view, modelpresenter 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 view, modelpresenter 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ư main, detail, search,… mỗi chức năng như vậy chứa đủ các lớp ở cả 3 layer view, modelpresenter 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à view, modelpresenter 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

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 view, model 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 CountryModelNameModel 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 presenter. CountriesController 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 presentercontroller 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 viewmodel. 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 viewpresenter 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 viewpresenter 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

Như đã nói, chúng ta cần xây dựng các interface để làm cầu nối giữa CountriesActivityCountriesPresenter. 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 CountriesActivityCountriesPresenter 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 1113 ở 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-controllerview-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() 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()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ư MVC, MVP 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 MVCMVP ở 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é.

Download Source Code Mẫu

Bạn có thể download source code mẫu của bài này ở đây.

Cảm ơn bạn đã đọc các bài viết của Yellow Code Books. Bạn hãy ủng hộ blog bằng cách:

– Đánh giá 5 sao ở mỗi bài viết nếu thấy thích.
– Comment bên dưới mỗi bài viết nếu có thắc mắc.
– Để lại địa chỉ email của bạn ở thanh bên phải để nhận được thông báo sớm nhất khi có bài viết mới.
– Chia sẻ các bài viết của Yellow Code Books đến nhiều người khác.
– Ủng hộ blog theo hướng dẫn ở thanh bên phải để blog ngày càng phát triển hơn.

Exit mobile version