+2

Scaling Android Architecture #4: Android Dependency Inversion — Avoid rewriting your app

Mayfest2023

Khi chúng ta xây dựng các ứng dụng di động, chúng ta thích dựa vào các external tools giúp đơn giản hóa công việc của chúng ta. HTPP or GraphQL clients, local storage, caching, camera, GPS, Bluetooth, analytics, payments và nhiều tác vụ phức tạp khác được xử lý bởi API hệ thống hoặc thư viện phổ biến của 3rd party.

Một lợi thế lớn của những tools này là chúng ta đã sẵn sàng triển khai để sử dụng. Chúng ta không cần phải viết nó, chúng ta không cần test nó, chúng ta chỉ cần cắm nó vào và sẵn sàng hoạt động. Nhưng sự đơn giản hóa tuyệt vời này đi kèm với chi phí ẩn. Chi phí của một khớp nối chặt chẽ (tight coupling).

System and libraries có thể thay đổi

Chúng ta không phải viết mã này, điều này tốt, nhưng đồng thời chúng ta không thể kiểm soát những thay đổi được đưa vào code. API có thể được sửa đổi, một số chức năng có thể không được dùng nữa, thậm chí đôi khi toàn bộ thư viện có thể được thay thế bằng một thư viện hoàn toàn mới. Bạn có thể biết DataStore thay thế SharedPreferences hoặc CameraX, là phiên bản kế thừa đơn giản hơn của API Camera2.

Quyết định Business có thể thay đổi

Tools giúp đơn giản hóa quá trình phát triển không phải là những công cụ duy nhất mà chúng ta phải kết nối với các ứng dụng. Nhiều vấn đề hơn là sự tích hợp của các công cụ mà business của chúng ta cần. Payment gateways, authentication providers hoặc app monitoring chỉ là một vài ví dụ. Vì quyết định về các tools này là do business đưa ra nên chúng ta không thể kiểm soát khi họ đổi ý. Nó có thể xảy ra sau một thời gian, chúng ta sẽ cần thêm một monitoring SDK khác hoặc thay thế một payment gateway bằng một cổng khác.

Thay đổi tool đơn giản có thể là rất nhiều công việc

Bây giờ, hãy tưởng tượng rằng chúng ta đã tích hợp Firebase Analytics trong ứng dụng. Chúng ta đã làm theo cách sau:

class HomeViewModel(private val analytics: FirebaseAnalytics) {
  init {
    analytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
      param(FirebaseAnalytics.Param.SCREEN_NAME, "home")
    }
  }
}

Đối tượng FirebaseAnalytics được injected vào ViewModel và được sử dụng để log event. Ứng dụng của chúng ta có 80 ViewModels khác nhau và tất cả chúng đều sử dụng cùng một phương pháp. Bây giờ doanh nghiệp đến gặp chúng ta và nói:

Nhóm analytics team không hài lòng khi làm việc với Firebase Analytics. Chúng tôi muốn thay thế nó bằng một giải pháp khác. Tool mới mà chúng tôi đã chọn rất đơn giản để thiết lập, vì vậy chúng ta có thể sử dụng nó cho tuần tới không? image.png

Bạn có thể tưởng tượng cảm giác của các mobile devs lúc này. Từ góc độ business, đó là một nhiệm vụ đơn giản, chúng ta muốn giữ các events giống nhau và chỉ gửi chúng đến một tool khác. Nhà cung cấp mới cho chúng ta thấy rằng việc kết nối SDK của họ với ứng dụng cực kỳ đơn giản nên có thể chỉ mất vài giờ.

Nhưng chúng ta biết rằng chúng ta cần cập nhật hàng chục ViewModels trong ứng dụng khi chúng trực tiếp sử dụng đối tượng FirebaseAnalytics. Rất có thể sẽ mất vài ngày nếu không muốn nói là vài tuần để chuyển giao các thay đổi.

Đây là một chi phí của một tight coupling. Codebase của chúng ta phụ thuộc vào external tool ở nhiều nơi. Việc triển khai mà chúng ta đã xây dựng được điều chỉnh cho phù hợp với một tool cụ thể mà chúng ta không thể dễ dàng thay thế bằng một tool khác.

🔃 Đảo ngược suy nghĩ của bạn

Ví dụ nhỏ này minh họa một vấn đề rất rộng có thể được tìm thấy trong nhiều ứng dụng dưới nhiều hình thức khác nhau. Nhưng may mắn thay, một số bộ óc lập trình thông minh đã xác định một giải pháp mà chúng ta gọi là nguyên tắc Dependency Inversion principle.. Nguyên tắc nêu rõ:

Các mô-đun cấp cao không nên nhập bất kỳ thứ gì từ các mô-đun cấp thấp. Cả hai nên phụ thuộc vào trừu tượng (ví dụ: interfaces).

Trừu tượng không nên phụ thuộc vào chi tiết. Chi tiết (triển khai cụ thể) nên phụ thuộc vào trừu tượng.

Như thường lệ, định nghĩa này khá trang trọng và không quá rõ ràng để giải thích. Hãy cố gắng đi sâu vào nó với một số ví dụ thực tế.

Ứng dụng của chúng ta là một sự trừu tượng và các tool chỉ là chi tiết

Logic của ứng dụng chúng ta là sự trừu tượng hóa một số vấn đề trong thế giới thực mà chúng ta cố gắng giải quyết bằng phần mềm. Logic trừu tượng này đôi khi cần thực hiện các hoạt động cụ thể như persisting data, nhận user’s location hoặc gửi một analytics event. Chúng ta ủy quyền các hoạt động này cho các external tools.

Khi cần tích hợp chúng, chúng ta thường bắt đầu nghĩ cách ứng dụng có thể sử dụng tool này. Suy nghĩ theo cách này là một sai lầm lớn vì nó cho rằng ứng dụng phụ thuộc vào tool. Chúng ta kết thúc với tight coupling giữa code của chúng ta và triển khai bên ngoài mà chúng ta không thể kiểm soát. Bằng cách này, chúng ta phá vỡ nguyên tắc Dependency Inversion nói rằng sự trừu tượng (logic của chúng ta) không nên phụ thuộc vào chi tiết (external tools).

image.png

Hãy suy nghĩ về nhu cầu ứng dụng của bạn

Sẽ không quá lời nếu tôi nói rằng công tắc đơn giản này có thể là một công cụ thay đổi cuộc chơi thực sự. Toàn bộ vấn đề là nghĩ xem logic của chúng ta mong đợi loại hành vi nào.

Khi chúng ta biết những kỳ vọng này, chúng ta có thể định nghĩa chúng trong code dưới dạng hợp đồng mà không cần triển khai thực tế. Dựa trên hợp đồng, chúng tôi có thể kết nối một công cụ đã chọn với ứng dụng bằng cách cung cấp một hành vi dự kiến.

Logic của ứng dụng của chúng ta phụ thuộc vào hợp đồng trừu tượng này nhưng nó không biết gì về tool cụ thể. Đây là những gì chúng ta gọi là Dependency Inversion. Nhu cầu của ứng dụng chúng ta được đáp ứng bằng một external tool nhưng ứng dụng không biết nó được thực hiện như thế nào và tool chính xác nào được sử dụng under the hood. image.png

Hãy xem việc thay thế chuột máy tính của bạn dễ dàng như thế nào

Chúng ta có thể coi một phần mềm như một cỗ máy PC chơi game. Nó cung cấp một chức năng trừu tượng để chạy các trò chơi điện tử của chúng ta. Nó được thực hiện nhờ vào game engine hệ điều hành, v.v.

Để di chuyển trong trò chơi, chúng ta cần chuột và bàn phím. Khi bắt đầu hành trình chơi trò chơi của mình, chúng ta có thể chọn thứ gì đó rẻ tiền và nâng cấp nó thành đồ thật cho game thủ bằng thiết bị game thủ sau này.

Chúng ta cũng muốn xem những gì đang diễn ra trong trò chơi để có thể chọn một màn hình. Họ có thể cung cấp độ phân giải, kích thước, tỷ lệ khung hình khác nhau nhưng trò chơi vẫn hoạt động cho dù chúng ta chọn loại nào.

Từ góc độ PC, việc chúng ta đang sử dụng công cụ nào không quan trọng. Nó chỉ xác định những gì cần thiết từ chuột hoặc màn hình. Đây là trách nhiệm của một công cụ để tương thích với các kỳ vọng do PC xác định. image.png

Hexagonal / Ports and Adapter architecture

Khái niệm này còn được gọi là kiến trúc Hexagonal hoặc kiến trúc PortsAdapter. Điểm mấu chốt của nó là duy trì low coupling giữa code của chúng ta và các external tools mà ứng dụng cần.

https://en.wikipedia.org/wiki/Hexagonal_architecture_(software)

Bằng cách đi theo con đường này, chúng ta có thể xây dựng một phần mềm như thể nó là một PC chơi game. Chúng ta tập trung vào việc giải quyết các vấn đề business, chúng ta xây dựng code của riêng mình và chúng ta tách nó khỏi code không thuộc sở hữu của chúng ta.

image.png

🚀 Hãy tạo một số code

Như chúng ta đã biết Dependency Inversion là gì và cách sử dụng nó để tách code của chúng ta khỏi các external tools, hãy thử áp dụng nó vào thực tế. Chúng ta sẽ đi qua ba trường hợp phổ biến mà chúng ta có thể tìm thấy trong nhiều ứng dụng di động.

Analytics

Tôi đã sử dụng analytics trước đó làm ví dụ, vì vậy hãy để tôi bắt đầu phần triển khai từ đó. Có nhiều nhà cung cấp dịch vụ analytics khác nhau và một trong số đó là SDK Firebase Analytics.

Để làm cho ứng dụng của chúng ta độc lập với tool cụ thể và cuối cùng có thể thay thế nó trong tương lai, chúng ta không thể trực tiếp sử dụng Events do Firebase cung cấp.

Chúng ta nên tự hỏi ứng dụng của mình cần những event nào. Giả sử chúng ta muốn biết khi nào người dùng vào một màn hình nhất định và khi nào anh ta nhấp vào một số nút. Chúng ta xác định một mô hình mô tả những kỳ vọng này.

sealed interface AnalyticsEvent {

  data class ScreenOpened(val screenName: String) : AnalyticsEvent

  data class ButtonClicked(val buttonName: String) : AnalyticsEvent
}

Sau đó, chúng ta tạo một interface cho phép chúng ta gửi các event.

interface Analytics {
  
  fun sendEvent(event: AnalyticsEvent)
}

Khi chúng ta chọn triển khai nó với Firebase, chúng ta cần chuyển đổi các event mà ứng dụng của chúng ta cần thành các event mà SKD bên ngoài có thể diễn giải.

@Singleton
class FirebaseAnalytics @Inject constructor(
  private val firebaseAnalytics: FirebaseAnalytics,
) : Analytics {
  
  override fun sendEvent(event: AnalyticsEvent) {
    when (event) {
      is AnalyticsEvent.ScreenOpened -> logScreenOpened(event.screenName)
      is AnalyticsEvent.ButtonClicked -> logButtonClicked(event.buttonName)
    }
  }  

  private fun logScreenOpened(screenName: String) {
    firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
      param(FirebaseAnalytics.Param.SCREEN_NAME, screenName)
    }
  }

  private fun logButtonClicked(buttonName: String) {
    firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SELECT_CONTENT) {
      param(FirebaseAnalytics.Param.CONTENT_TYPE, "button")
      param(FirebaseAnalytics.Param.ITEM_ID, buttonName)
    }
  }
}

Như bạn có thể thấy, ứng dụng không sử dụng các event giống như Firebase chỉ định. Chúng ta tập trung vào việc xác định loại event mà ứng dụng của chúng ta cần và chúng ta chuyển đổi chúng thành event Firebase. Bằng cách này, sự phụ thuộc được đảo ngược và ứng dụng của chúng ta sẽ đưa ra các quy tắc ngay bây giờ 😎

Key-value storage

Chúng ta thường cần lưu trữ một số dữ liệu đơn giản như session tokens, chủ đề đã chọn của ứng dụng hoặc bất kỳ giá trị nào khác giống như cài đặt. Chúng ta chọn sử dụng bộ Key-value storage cho mục đích này.

interface Storage {

  suspend fun getString(key: String): String?

  suspend fun saveString(key: String, value: String?)

  suspend fun getInt(key: String): Int?

  suspend fun saveInt(key: String, value: Int?)

  ...
}

Storage này có thể được đưa vào ứng dụng của chúng tôi và được sử dụng để lưu trữ một số dữ liệu.

// We use an abstraction and we don't know what tool is under the hood
class HomeViewModel(private val storage: Storage) {
  
  fun selectTheme(theme: Theme) {
    storage.saveString(theme.name)
  }
}

Bây giờ rất đơn giản để lựa chọn giữa các triển khai khác nhau. Trước đây chúng ta dựa vào SharedPreferences.

@Singleton
class SharedPreferencesStorage @Inject constructor(
  private val sharedPreferences: SharedPreferences
) : Storage {
 
  override suspend fun getString(key: String): String? {
    return sharedPreferences.getString(key, null)
  }

  override suspend fun saveString(key: String, value: String?) {
    with(sharedPreferences.edit()) {
      putString(key, value)
      apply()
    }
  }

  override suspend fun getInt(key: String): Int? {
    return sharedPreferences.getInt(key, null)
  }

  override suspend fun saveInt(key: String, value: Int?) {
    with(sharedPreferences.edit()) {
      putInt(key, value)
      apply()
    }
  }
}

Cách đây một thời gian, Google đã giới thiệu thư viện Jetpack DataStore mới được cho là sẽ thay thế SharedPreferences. Nhờ Dependency Inversion, việc di chuyển khá dễ dàng. Chúng ta chỉ tạo một triển khai mới của Storage interface.

@Singleton
class DataStoreStorage @Inject constructor(
  private val dataStore: DataStore<Preferences>,
) : Storage {

  override suspend fun getString(key: String): String? {
    return dataStore.data.first()[key]
  }

  override suspend fun saveString(key: String, value: String?) {
    dataStore.edit { prefs ->
      prefs[key] = value
    }
  }

  override suspend fun getInt(key: String): Int? {
    return dataStore.data.first()[key]
  }

  override suspend fun saveInt(key: String, value: Int?) {
    dataStore.edit { prefs ->
      prefs[key] = value
    }
  }
}

Và chúng ta thay thế nó trong biểu đồ DI của chúng ta. Giả sử chúng ta sử dụng Hilt ở đây, nhưng chúng ta cũng có thể làm tương tự với Dagger hoặc Koin.

@Module
@InstallIn(SingletonComponent::class)
interface StorageModule {

  // Remove old implementation
  // @Binds
  // fun storage(impl: SharedPreferencesStorage): Storage

  // And replace it with a new one
  @Binds
  fun storage(impl: DataStoreStorage): Storage
}

Lấy user’s location

Trong một số trường hợp, chúng ta cần biết người dùng đang ở đâu. Hệ thống Android cung cấp cho chúng ta một API đơn giản để truy cập Vị trí đối tượng. Nhưng object này có rất nhiều chức năng khác nhau và hiển thị nhiều dữ liệu khác nhau.

Trong ứng dụng, chúng ta chỉ cần biết tọa độ kinh độ và vĩ độ của User. Một lần nữa, chúng ta tập trung vào các nhu cầu của ứng dụng của chúng ta. Chúng ta tạo ra một model có thể chứa thông tin cần thiết và không có gì khác.

data class Coordinates(val latitude: Double, val longitude: Double)

Sau đó, chúng ta xác định một interface để truy cập thông tin này.

interface UserLocation {

  suspend fun getCoordinates(): Coordinates?
}

Và cuối cùng, chúng ta tạo triển khai dựa trên API do hệ thống Android cung cấp.

@Singleton
class FusedUserLocation @Inject constructor(
  private val fusedLocationClient: FusedLocationProviderClient,
) : UserLocation {

  override suspend fun getCoordinates(): Coordinates? {
    return suspendCancellableCoroutine { continuation ->
      fusedLocationClient.lastLocation.addOnSuccessListener { location ->
        val coordinates = Coordinates(
          latitude = location.latitude,
          longitude = location.longitude,
        )
        continuation.resume(coordinates)
      }
    }
  }
}

Ở đây chúng ta có thể thấy một lợi ích bổ sung của việc tách các external tools khỏi code của chúng ta. Trong ứng dụng, chúng ta muốn sử dụng Kotlin Coroutines cho các hoạt động không đồng bộ. UserLocation interface của chúng ta dự kiến sẽ dựa vào suspend function.

System location API không cung cấp hỗ trợ trực tiếp cho Coroutines và thay vào đó sử dụng lệnh gọi lại. Nhưng đây không phải là vấn đề đối với chúng ta vì chúng ta có thể dễ dàng ẩn chi tiết này và biến nó thành một suspend function.. Bằng cách này, ứng dụng của chúng ta có được những gì nó cần và không biết gì về cách thức hoạt động của tool này.

📓 Summary

Đây chỉ là một vài ví dụ nhưng chúng ta nên xem xét áp dụng chiến lược tương tự cho các thư viện và API hệ thống khác mà chúng ta tích hợp trong các ứng dụng của mình.

Việc triển khai bên ngoài có thể mang lại nhiều giá trị cho dự án nhưng đồng thời chúng ta nên bảo vệ codebase của mình khỏi code không thuộc về chúng ta. Khi bạn quyết định dùng thử tại đây, bạn có thể tìm thấy một bảng cheat ngắn:

  1. Hãy suy nghĩ về nhu cầu của ứng dụng và cố gắng xác định một hợp đồng trừu tượng. Bạn đang kết nối tool với ứng dụng chứ không phải ứng dụng với tool.
  2. Không bao gồm code cụ thể của tool trong hợp đồng. Hợp đồng thuộc về ứng dụng và nó sẽ không biết gì về các external tools.
  3. Giữ cho việc thực hiện hợp đồng mỏng. Code triển khai hợp đồng và kết nối external tools phụ thuộc rất nhiều vào tool cụ thể. Càng đơn giản thì càng tốt.

Nguồn: https://medium.com/itnext/domain-driven-android-building-a-model-which-makes-sense-badb774c606d


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí