Lớp dữ liệu

Trong khi lớp giao diện người dùng chứa trạng thái liên quan đến giao diện người dùng và logic giao diện người dùng, thì lớp dữ liệu chứa dữ liệu ứng dụnglogic kinh doanh. Logic kinh doanh là những gì mang lại giá trị cho ứng dụng của bạn—được tạo ra từ các quy tắc kinh doanh trong thế giới thực xác định cách tạo, lưu trữ và thay đổi dữ liệu ứng dụng.

Việc phân tách các mối quan ngại này cho phép sử dụng lớp dữ liệu trên nhiều màn hình, chia sẻ thông tin giữa các phần khác nhau của ứng dụng và tái tạo logic kinh doanh bên ngoài giao diện người dùng để thử nghiệm đơn vị. Để biết thêm thông tin về các lợi ích của lớp dữ liệu, hãy xem trang Tổng quan về cấu trúc.

Kiến trúc lớp dữ liệu

Lớp dữ liệu được tạo thành từ các kho lưu trữ, mỗi kho dữ liệu có thể chứa từ 0 đến nhiều nguồn dữ liệu. Bạn nên tạo một lớp kho lưu trữ cho từng loại dữ liệu khác nhau mà bạn xử lý trong ứng dụng. Ví dụ: bạn có thể tạo một lớp MoviesRepository cho dữ liệu liên quan đến phim hoặc một lớp PaymentsRepository cho dữ liệu liên quan đến các khoản thanh toán.

Trong một cấu trúc thông thường, kho lưu trữ của lớp dữ liệu cung cấp dữ liệu cho phần còn lại của ứng dụng và phụ thuộc vào các nguồn dữ liệu.
Hình 1. Vai trò của lớp dữ liệu trong cấu trúc ứng dụng.

Các lớp kho lưu trữ chịu trách nhiệm về:

  • Hiển thị dữ liệu cho phần còn lại của ứng dụng.
  • Tập trung các thay đổi vào dữ liệu.
  • Giải quyết xung đột giữa nhiều nguồn dữ liệu.
  • Tóm tắt các nguồn dữ liệu từ phần còn lại của ứng dụng.
  • Chứa logic nghiệp vụ.

Mỗi lớp nguồn dữ liệu nên có trách nhiệm làm việc với chỉ một nguồn dữ liệu duy nhất, có thể là một tệp, nguồn mạng hoặc cơ sở dữ liệu cục bộ. Các lớp nguồn dữ liệu là cầu nối giữa ứng dụng và hệ thống để thao tác dữ liệu.

Các lớp khác trong hệ thống phân cấp không được trực tiếp truy cập vào các nguồn dữ liệu; điểm truy cập cho lớp dữ liệu luôn là các lớp kho lưu trữ. Các lớp chủ thể trạng thái (xem hướng dẫn về lớp giao diện người dùng) hoặc các lớp trường hợp sử dụng (xem hướng dẫn về lớp trên miền) không được có nguồn dữ liệu như một phần phụ thuộc trực tiếp. Việc sử dụng các lớp kho lưu trữ làm điểm truy cập cho phép các lớp khác nhau của kiến trúc mở rộng quy mô độc lập.

Dữ liệu mà lớp này hiển thị phải là bất biến để các lớp khác không thể củng cố dữ liệu. Việc này sẽ có nguy cơ khiến các giá trị của lớp bị chuyển sang trạng thái không nhất quán. Dữ liệu bất biến cũng có thể được xử lý một cách an toàn thông qua nhiều luồng. Hãy xem mục về luồng này để biết thêm chi tiết.

Sau khi áp dụng các phương pháp hay nhất về thao tác chèn phần phụ thuộc, kho lưu trữ sẽ lấy nguồn dữ liệu làm các phần phụ thuộc trong hàm tạo:

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }

Hiển thị các API

Các lớp trong lớp dữ liệu thường hiển thị các hàm để thực hiện lệnh gọi một lần: Tạo, Đọc, Cập nhật và Xóa (CRUD) hoặc để được thông báo về sự thay đổi dữ liệu theo thời gian. Lớp dữ liệu phải hiển thị những nội dung sau cho từng trường hợp sau:

  • Thao tác một lần: Lớp dữ liệu cần hiển thị các hàm tạm ngưng trong Kotlin; và đối với ngôn ngữ lập trình Java, lớp dữ liệu cần hiển thị các hàm cung cấp lệnh gọi lại để thông báo kết quả của thao tác hoặc các loại Single, Maybe hay Completable của RxJava.
  • Để nhận thông báo về các thay đổi của dữ liệu theo thời gian: Lớp dữ liệu cần hiển thị các luồng trong Kotlin; và đối với ngôn ngữ lập trình Java, lớp dữ liệu cần hiển thị một lệnh gọi lại phát ra dữ liệu mới hoặc loại RxJava Observable hoặc Flowable.
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

Quy ước đặt tên trong hướng dẫn này

Trong hướng dẫn này, các lớp kho lưu trữ được đặt tên theo dữ liệu mà chúng chịu trách nhiệm. Quy ước như sau:

loại dữ liệu + Kho lưu trữ.

Ví dụ: NewsRepository, MoviesRepository hoặc PaymentsRepository.

Các lớp nguồn dữ liệu được đặt tên theo dữ liệu mà các lớp này chịu trách nhiệm và nguồn mà chúng sử dụng. Quy ước như sau:

loại dữ liệu + loại nguồn + DataSource.

Đối với loại dữ liệu, hãy sử dụng Điều khiển từ xa hoặc Địa phương chung hơn vì cách triển khai có thể thay đổi. Ví dụ: NewsRemoteDataSource hoặc NewsLocalDataSource. Để cụ thể hơn trong trường hợp nguồn là quan trọng, hãy sử dụng loại nguồn. Ví dụ: NewsNetworkDataSource hoặc NewsDiskDataSource.

Không đặt tên nguồn dữ liệu dựa trên thông tin triển khai—ví dụ: UserSharedPreferencesDataSource—vì các kho lưu trữ mà sử dụng nguồn dữ liệu đó sẽ không biết cách lưu dữ liệu. Nếu tuân theo quy tắc này, bạn có thể thay đổi cách triển khai nguồn dữ liệu (ví dụ: di chuyển từ SharedPreferences tới DataStore ) mà không ảnh hưởng đến lớp gọi nguồn đó.

Nhiều cấp độ của các kho lưu trữ

Trong một số trường hợp liên quan đến các yêu cầu kinh doanh phức tạp hơn, một kho lưu trữ có thể cần phải phụ thuộc vào những kho lưu trữ khác. Điều này có thể là do dữ liệu liên quan là tổng hợp từ nhiều nguồn dữ liệu hoặc do trách nhiệm cần được đóng gói trong một lớp kho lưu trữ khác.

Ví dụ: một kho lưu trữ xử lý dữ liệu xác thực người dùng, UserRepository, có thể phụ thuộc vào các kho lưu trữ khác như LoginRepositoryRegistrationRepository để đáp ứng các yêu cầu của kho lưu trữ đó.

Trong ví dụ này, UserRepository phụ thuộc vào hai lớp kho lưu trữ khác:
    LoginRepository, phụ thuộc vào các nguồn dữ liệu đăng nhập khác; và 
    RegistrationRepository, phụ thuộc vào các nguồn dữ liệu đăng ký khác.
Hình 2. Biểu đồ phần phụ thuộc của một kho lưu trữ phụ thuộc vào những kho lưu trữ khác.

Nguồn đáng tin cậy

Điều quan trọng là mỗi kho lưu trữ xác định được một nguồn dữ liệu đáng tin cậy. Nguồn đáng tin cậy luôn chứa dữ liệu nhất quán, chính xác và mới nhất. Trên thực tế, dữ liệu do kho lưu trữ hiển thị phải luôn là dữ liệu đến trực tiếp từ nguồn đáng tin cậy.

Nguồn đáng tin cậy có thể là nguồn dữ liệu – ví dụ: cơ sở dữ liệu – hoặc thậm chí là một bộ nhớ đệm trong bộ nhớ có thể thuộc kho lưu trữ. Các kho lưu trữ kết hợp nhiều nguồn dữ liệu khác nhau và giải quyết mọi xung đột tiềm ẩn giữa các nguồn dữ liệu để cập nhật nguồn đáng tin cậy thường xuyên hoặc do sự kiện nhập của người dùng.

Các kho lưu trữ khác nhau trong ứng dụng của bạn có thể có các nguồn đáng tin cậy khác nhau. Ví dụ: lớp LoginRepository có thể sử dụng bộ nhớ đệm làm nguồn đáng tin cậy và lớp PaymentsRepository có thể sử dụng nguồn dữ liệu mạng.

Để cung cấp hỗ trợ ưu tiên ngoại tuyến, một nguồn dữ liệu địa phương—chẳng hạn như cơ sở dữ liệu—là nguồn đáng tin cậy được đề xuất.

Luồng

Tác vụ gọi nguồn và kho lưu trữ cần an toàn cho luồng chính – an toàn để gọi từ luồng chính. Các lớp này chịu trách nhiệm chuyển phương thức thực thi logic sang luồng phù hợp khi thực hiện các thao tác chặn dài hạn. Ví dụ: nguồn dữ liệu phải an toàn cho luồng chính để đọc từ một tệp hoặc để kho lưu trữ thực hiện việc lọc tốn kém trên một danh sách lớn.

Xin lưu ý rằng hầu hết các nguồn dữ liệu đều đã cung cấp các API an toàn cho luồng chính, chẳng hạn như các lệnh gọi phương thức tạm ngưng do Room, Retrofit hoặc Ktor cung cấp. Kho lưu trữ của bạn có thể tận dụng các API này khi có sẵn.

Để tìm hiểu thêm về xử lý luồng, hãy xem hướng dẫn xử lý nền. Đối với người dùng Kotlin, họ nên sử dụng coroutine. Hãy xem phần Chạy các tác vụ trên Android trong luồng nền để biết các tuỳ chọn đề xuất cho ngôn ngữ lập trình Java.

Lifecycle

Phiên bản của các lớp trong lớp dữ liệu vẫn sẽ còn trong bộ nhớ miễn là chúng có thể truy cập được từ thư mục thu thập rác gốc—thường là được tham chiếu từ các đối tượng khác trong ứng dụng của bạn.

Nếu một lớp chứa dữ liệu trong bộ nhớ—ví dụ: bộ nhớ đệm—bạn có thể muốn sử dụng lại cùng một phiên bản của lớp đó trong một khoảng thời gian cụ thể. Đây cũng được gọi là vòng đời của phiên bản lớp.

Nếu trách nhiệm của lớp đó là quan trọng đối với toàn bộ ứng dụng, bạn có thể đặt phạm vi một phiên bản của lớp đó cho lớp Application. Điều này làm cho phiên bản này tuân theo vòng đời của ứng dụng. Ngoài ra, nếu bạn chỉ cần sử dụng lại cùng một phiên bản trong một quy trình cụ thể trong ứng dụng—ví dụ: quy trình đăng ký hoặc đăng nhập—thì bạn nên đặt phạm vi của phiên bản đó cho lớp sở hữu vòng đời của quy trình đó. Ví dụ: bạn có thể đặt phạm vi RegistrationRepository chứa dữ liệu trong bộ nhớ thành RegistrationActivity hoặc biểu đồ điều hướng của quy trình đăng ký ,

Vòng đời của từng phiên bản là một yếu tố quan trọng để quyết định cách cung cấp các phần phụ thuộc trong ứng dụng. Bạn nên làm theo các phương pháp hay nhất về cách chèn phần phụ thuộc vào những phần phụ thuộc mà bạn quản lý và có thể được đặt phạm vi vào các vùng chứa phần phụ thuộc. Để tìm hiểu thêm về xác định phạm vi trong Android, hãy xem phần Xác định phạm vi trong Android và Hilt trên blog.

Đại diện cho các mô hình kinh doanh

Các mô hình dữ liệu mà bạn muốn hiển thị từ lớp dữ liệu có thể là một tập hợp con chứa thông tin mà bạn nhận được từ nhiều nguồn dữ liệu. Lý tưởng nhất là các nguồn dữ liệu khác nhau—cả mạng và cục bộ—chỉ cần trả về thông tin mà ứng dụng cần; nhưng điều này thường không đúng.

Ví dụ: hãy tưởng tượng một máy chủ API Tin tức không chỉ trả về thông tin bài viết mà còn chỉnh sửa lịch sử, nhận xét của người dùng và một số siêu dữ liệu:

data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)

Ứng dụng chỉ hiển thị nội dung của bài viết trên màn hình cùng với thông tin cơ bản về tác giả nên không cần nhiều thông tin đến vậy về bài viết. Bạn nên tách riêng các lớp mô hình và yêu cầu kho lưu trữ chỉ hiển thị dữ liệu mà các lớp khác trong hệ thống phân cấp yêu cầu. Ví dụ: dưới đây là cách bạn có thể cắt bớt ArticleApiModel từ mạng để hiển thị lớp mô hình Article cho lớp miền và giao diện người dùng:

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

Việc tách các lớp mô hình có lợi như sau:

  • Cách này giúp tiết kiệm bộ nhớ ứng dụng bằng cách giảm dữ liệu xuống chỉ còn những dữ liệu cần thiết.
  • Ứng dụng này điều chỉnh các loại dữ liệu bên ngoài cho phù hợp với các loại dữ liệu mà ứng dụng sử dụng—ví dụ: ứng dụng của bạn có thể sử dụng một loại dữ liệu khác để thể hiện ngày tháng.
  • Trường hợp này giúp tách biệt đáng kể các mối quan ngại–ví dụ: các thành viên trong một nhóm lớn có thể làm việc riêng lẻ trên các mạng và các lớp của giao diện người dùng của một tính năng nếu lớp mô hình được xác định trước.

Bạn cũng có thể mở rộng phương pháp này và xác định các lớp mô hình riêng biệt trong các phần khác của cấu trúc ứng dụng, ví dụ: trong các lớp nguồn dữ liệu và ViewModels. Tuy nhiên, điều này yêu cầu bạn phải xác định thêm các lớp và logic mà bạn nên ghi nhận và kiểm tra đúng cách. Bạn nên tạo ít nhất một mô hình mới trong mọi trường hợp mà nguồn dữ liệu nhận được dữ liệu không khớp với thông tin mà phần còn lại của ứng dụng dự kiến.

Các loại thao tác với dữ liệu

Lớp dữ liệu có thể xử lý các loại thao tác khác nhau tùy theo mức độ quan trọng của chúng: thao tác do giao diện người dùng điều hướng, do ứng dụng điều hướng và do hoạt động kinh doanh điều hướng.

Thao tác do giao diện người dùng điều hướng

Các thao tác do giao diện người dùng điều hướng chỉ có liên quan khi người dùng đang sử dụng một màn hình cụ thể và các thao tác đó sẽ bị huỷ khi người dùng rời khỏi màn hình đó. Ví dụ: hiển thị một số dữ liệu thu thập được từ cơ sở dữ liệu đó.

Thao tác do giao diện người dùng điều hướng thường do lớp giao diện người dùng kích hoạt và tuân theo vòng đời của lệnh gọi, ví dụ: vòng đời của ViewModel. Hãy xem mục Tạo yêu cầu mạng để xem ví dụ về thao tác do giao diện người dùng điều hướng.

Thao tác do ứng dụng điều hướng

Các thao tác do ứng dụng điều hướng phù hợp với điều kiện ứng dụng đang mở. Nếu ứng dụng bị đóng hoặc quy trình bị huỷ, thì các thao tác này sẽ bị huỷ. Một ví dụ là lưu vào bộ nhớ đệm kết quả của một yêu cầu mạng để có thể sử dụng sau này khi cần thiết. Xem phần Triển khai lưu vào bộ nhớ đệm dữ liệu trong bộ nhớ để tìm hiểu thêm.

Các thao tác này thường tuân theo vòng đời của lớp Application hoặc lớp dữ liệu. Ví dụ: hãy xem mục Làm cho thao tác hoạt động lâu hơn màn hình.

Thao tác do hoạt động kinh doanh điều hướng

Bạn không thể huỷ các thao tác do hoạt động kinh doanh điều hướng. Chúng cần vẫn tồn tại sau quá trình xử lý bị gián đoạn. Một ví dụ là việc hoàn tất tải ảnh lên mà người dùng muốn đăng trong hồ sơ của họ.

Bạn nên sử dụng WorkManager cho các thao tác do hoạt động kinh doanh điều hướng. Hãy xem mục Lên lịch tác vụ bằng WorkManager để tìm hiểu thêm.

Lỗi hiển thị

Các tương tác với kho lưu trữ và nguồn dữ liệu có thể thành công hoặc đưa ra một ngoại lệ khi xảy ra lỗi. Đối với các coroutine và quy trình, bạn nên sử dụng cơ chế xử lý lỗi tích hợp sẵn của Kotlin. Cho lỗi có thể do hàm tạm ngưng kích hoạt, hãy sử dụng khối try/catch khi phù hợp; còn trong luồng, hãy sử dụng catch toán tử. Với cách tiếp cận này, lớp giao diện người dùng dự kiến sẽ xử lý các trường hợp ngoại lệ khi gọi lớp dữ liệu.

Lớp dữ liệu có thể hiểu và xử lý nhiều loại lỗi, cũng như hiển thị các lỗi đó bằng cách sử dụng các trường hợp ngoại lệ tuỳ chỉnh, ví dụ: UserNotAuthenticatedException.

Để tìm hiểu thêm về lỗi trong coroutine, hãy xem phần Ngoại lệ trong coroutine trên blog.

Tác vụ chung

Các phần sau đây trình bày ví dụ về cách sử dụng và thiết kế cấu trúc lớp dữ liệu để thực hiện một số tác vụ nhất định trong các ứng dụng Android. Các ví dụ dựa trên ứng dụng Tin tức điển hình được đề cập trước đây trong hướng dẫn.

Gửi yêu cầu kết nối mạng

Yêu cầu mạng là một trong những tác vụ phổ biến nhất mà một ứng dụng Android có thể thực hiện. Ứng dụng Tin tức cần hiển thị cho người dùng những tin tức mới nhất được tìm nạp từ mạng. Do đó, ứng dụng này cần có một lớp nguồn dữ liệu để quản lý các thao tác mạng: NewsRemoteDataSource. Để hiển thị thông tin cho phần còn lại của ứng dụng, hệ thống sẽ tạo một kho lưu trữ mới xử lý các thao tác đối với dữ liệu tin tức được tạo: NewsRepository.

Yêu cầu là bạn phải luôn cập nhật tin tức mới nhất khi người dùng mở màn hình. Do đó, đây là một thao tác do giao diện người dùng điều hướng.

Tạo nguồn dữ liệu

Nguồn dữ liệu cần hiển thị một hàm trả về tin tức mới nhất: danh sách các phiên bản ArticleHeadline. Nguồn dữ liệu cần cung cấp một cách an toàn cho luồng chính để nhận tin tức mới nhất từ mạng. Do đó, cần phải nhận một phần phụ thuộc trên CoroutineDispatcher hoặc Executor để chạy tác vụ đó.

Gửi yêu cầu kết nối mạng là một lệnh gọi một lần do phương thức fetchLatestNews() mới xử lý:

class NewsRemoteDataSource(
  private val newsApi: NewsApi,
  private val ioDispatcher: CoroutineDispatcher
) {
    /**
     * Fetches the latest news from the network and returns the result.
     * This executes on an IO-optimized thread pool, the function is main-safe.
     */
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        // Move the execution to an IO-optimized thread since the ApiService
        // doesn't support coroutines and makes synchronous requests.
        withContext(ioDispatcher) {
            newsApi.fetchLatestNews()
        }
    }

// Makes news-related network synchronous requests.
interface NewsApi {
    fun fetchLatestNews(): List<ArticleHeadline>
}

Giao diện NewsApi ẩn hoạt động triển khai API mạng máy khách; việc này không ảnh hưởng đến việc giao diện có được Retrofit hoặc HttpURLConnection hỗ trợ hay không. Việc dựa vào giao diện giúp các nội dung triển khai API có thể tuỳ chỉnh trong ứng dụng của bạn.

Tạo kho lưu trữ

Vì không cần thêm logic trong lớp kho lưu trữ cho tác vụ này, NewsRepository đóng vai trò là proxy cho nguồn dữ liệu mạng. Những lợi ích của việc thêm lớp trừu tượng bổ sung này sẽ được giải thích trong mục lưu vào bộ nhớ đệm dữ liệu trong bộ nhớ.

// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource
) {
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        newsRemoteDataSource.fetchLatestNews()
}

Để tìm hiểu cách sử dụng lớp kho lưu trữ trực tiếp từ lớp giao diện người dùng, hãy xem hướng dẫn về lớp giao diện người dùng.

Triển khai lưu vào bộ nhớ đệm dữ liệu trong bộ nhớ

Giả sử một yêu cầu mới được đưa ra cho ứng dụng News (Tin tức): khi người dùng mở màn hình, tin tức đã lưu vào bộ nhớ đệm phải hiển thị cho người dùng nếu trước đó, người dùng đã thực hiện yêu cầu. Nếu không, ứng dụng sẽ tạo một yêu cầu mạng để tìm nạp tin tức mới nhất.

Dựa trên yêu cầu mới này, ứng dụng phải lưu giữ những tin tức mới nhất trong bộ nhớ trong khi người dùng mở ứng dụng. Do đó, đây là một thao tác do ứng dụng điều hướng.

Bộ nhớ đệm

Bạn có thể lưu giữ dữ liệu trong khi người dùng đang ở trong ứng dụng của bạn bằng cách thêm tính năng lưu vào bộ nhớ đệm dữ liệu trong bộ nhớ. Bộ nhớ đệm được dùng để lưu một số thông tin trong bộ nhớ trong một khoảng thời gian cụ thể—trong trường hợp này, miễn là người dùng vẫn ở trong ứng dụng. Các nội dung triển khai bộ nhớ đệm có thể có nhiều dạng khác nhau. Nó có thể khác nhau từ một biến đơn giản đến một lớp phức tạp hơn giúp bảo vệ khỏi các thao tác đọc/ghi trên nhiều chuỗi. Tuỳ thuộc vào trường hợp sử dụng, bạn có thể triển khai lưu vào bộ nhớ đệm trong kho lưu trữ hoặc các lớp nguồn dữ liệu.

Lưu vào bộ nhớ đệm kết quả của yêu cầu mạng

Để đơn giản, NewsRepository sử dụng một biến có thể thay đổi để lưu vào bộ nhớ đệm các tin tức mới nhất. Để bảo vệ lượt đọc và ghi từ nhiều luồng, Mutex sẽ được sử dụng. Để tìm hiểu thêm về trạng thái và tính năng đồng thời có thể thay đổi, hãy xem tài liệu về Kotlin.

Hoạt động triển khai sau đây sẽ lưu vào bộ nhớ đệm tin tức mới nhất trong một biến ở kho lưu trữ được bảo vệ chống ghi bằng Mutex. Nếu kết quả của yêu cầu mạng thành công, thì dữ liệu sẽ được gán cho biến latestNews.

class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}

Làm cho thao tác hoạt động lâu hơn màn hình

Nếu người dùng rời khỏi màn hình trong khi yêu cầu kết nối mạng đang diễn ra, thì yêu cầu đó sẽ bị huỷ và kết quả sẽ không được lưu vào bộ nhớ đệm. NewsRepository không được sử dụng CoroutineScope của lệnh gọi để thực hiện logic này. Thay vào đó, NewsRepository nên sử dụng CoroutineScope gắn với vòng đời của lệnh gọi. Việc tìm nạp tin tức mới nhất phải là một thao tác do ứng dụng điều hướng.

Để làm theo các phương pháp hay nhất về tính năng chèn phần phụ thuộc, NewsRepository sẽ nhận được phạm vi dưới dạng thông số trong hàm tạo thay vì tự tạo CoroutineScope riêng. Vì kho lưu trữ sẽ thực hiện hầu hết công việc trong chuỗi nền, nên bạn phải định cấu hình CoroutineScope bằng Dispatchers.Default hoặc với nhóm chuỗi riêng.

class NewsRepository(
    ...,
    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
    private val externalScope: CoroutineScope
) { ... }

Do NewsRepository đã sẵn sàng thực hiện các thao tác do ứng dụng điều hướng với CoroutineScope bên ngoài, nên kho lữu trữ này phải thực hiện lệnh gọi đến nguồn dữ liệu và lưu kết quả bằng một coroutine mới bắt đầu bằng phạm vi đó:

class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource,
    private val externalScope: CoroutineScope
) {
    /* ... */

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        return if (refresh) {
            externalScope.async {
                newsRemoteDataSource.fetchLatestNews().also { networkResult ->
                    // Thread-safe write to latestNews.
                    latestNewsMutex.withLock {
                        latestNews = networkResult
                    }
                }
            }.await()
        } else {
            return latestNewsMutex.withLock { this.latestNews }
        } 
    }
}

async được dùng để bắt đầu coroutine trong phạm vi bên ngoài. await được gọi trên coroutine mới để tạm ngưng cho đến khi yêu cầu mạng trở lại và kết quả được lưu vào bộ nhớ đệm. Nếu vào thời điểm đó người dùng vẫn ở trên màn hình, thì họ sẽ thấy tin tức mới nhất; nếu người dùng di chuyển khỏi màn hình, await sẽ bị huỷ nhưng logic bên trong async tiếp tục thực thi.

Xem blog này bài đăng để tìm hiểu thêm về các mẫu cho CoroutineScope.

Lưu và truy xuất dữ liệu từ ổ đĩa

Giả sử bạn muốn lưu dữ liệu như tin tức đã đánh dấu và tuỳ chọn của người dùng. Kiểu dữ liệu này cần tiếp tục có hiệu lực sau khi ứng dụng bị buộc tắt và có thể truy cập được ngay cả khi người dùng không kết nối mạng.

Nếu dữ liệu bạn đang xử lý cần tiếp tục có hiệu lực sau khi ứng dụng bị buộc tắt, thì bạn phải lưu trữ dữ liệu đó trên ổ đĩa theo một trong các cách sau:

  • Đối với các tập dữ liệu lớn cần được truy vấn, cần tính toàn vẹn tham chiếu hoặc cần cập nhật một phần, hãy lưu dữ liệu trong Cơ sở dữ liệu phòng. Trong ví dụ về ứng dụng Tin tức, các tin bài hoặc tác giả có thể được lưu trong cơ sở dữ liệu.
  • Đối với các tập dữ liệu nhỏ chỉ cần được truy xuất và đặt (không phải truy vấn hoặc cập nhật một phần), hãy sử dụng DataStore. Trong ví dụ về ứng dụng Tin tức, định dạng ngày mà người dùng ưu tiên hoặc các tuỳ chọn hiển thị khác có thể được lưu trong DataStore.
  • Đối với các khối dữ liệu như đối tượng JSON, hãy sử dụng tệp.

Như đã đề cập trong mục Nguồn đáng tin cậy, mỗi nguồn dữ liệu chỉ hoạt động với một nguồn và phản hồi với một loại dữ liệu cụ thể (ví dụ: News, Authors , NewsAndAuthors hoặc UserPreferences). Các lớp sử dụng nguồn dữ liệu đó không nên biết cách lưu dữ liệu – ví dụ: trong cơ sở dữ liệu hoặc trong tệp.

Phòng đóng vai trò nguồn dữ liệu

Vì mỗi nguồn dữ liệu sẽ có trách nhiệm chỉ làm việc với một nguồn duy nhất cho một loại dữ liệu cụ thể, nên nguồn dữ liệu Phòng sẽ nhận được một đối tượng truy cập dữ liệu (DAO) hoặc chính cơ sở dữ liệu dưới dạng thông số. Ví dụ: NewsLocalDataSource có thể lấy một phiên bản của NewsDao làm thông số và AuthorsLocalDataSource có thể lấy một phiên bản của AuthorsDao.

Trong một số trường hợp, nếu không cần thêm logic, bạn có thể đưa trực tiếp DAO vào kho lưu trữ, vì DAO là một giao diện mà bạn có thể dễ dàng thay thế trong các thử nghiệm.

Để tìm hiểu thêm về cách làm việc với API phòng, hãy xem Hướng dẫn về phòng.

DataStore đóng vai trò nguồn dữ liệu

DataStore rất phù hợp để lưu trữ các cặp khóa-giá trị như các chế độ cài đặt người dùng. Ví dụ có thể bao gồm định dạng thời gian, tuỳ chọn thông báo và liệu hiển thị hay ẩn các mục tin tức sau khi người dùng đã đọc các định dạng đó. DataStore cũng có thể lưu trữ các đối tượng đã nhập bằng các vùng đệm giao thức.

Giống như với bất kỳ đối tượng nào khác, một nguồn dữ liệu do DataStore hỗ trợ phải chứa dữ liệu tương ứng với một loại nhất định hoặc với một phần nhất định của ứng dụng. Điều này thậm chí còn đúng hơn với DataStore, vì các lượt đọc DataStore hiển thị dưới dạng một luồng được gửi đi mỗi khi giá trị được cập nhật. Vì lý do này, bạn nên lưu trữ các tuỳ chọn liên quan trong cùng một DataStore.

Ví dụ: bạn có thể có một NotificationsDataStore chỉ xử lý các tuỳ chọn liên quan đến thông báo và một NewsPreferencesDataStore chỉ xử lý các tuỳ chọn liên quan đến màn hình tin tức. Bằng cách đó, bạn có thể đặt phạm vi cập nhật tốt hơn, vì luồng newsScreenPreferencesDataStore.data chỉ phát ra khi một tuỳ chọn ưu tiên liên quan đến màn hình đó thay đổi. Điều này cũng có nghĩa là vòng đời của đối tượng có thể ngắn hơn vì đối tượng chỉ có thể tồn tại khi màn hình tin tức hiển thị.

Để tìm hiểu thêm về cách làm việc với API DataStore, hãy xem hướng dẫn về DataStore.

Tệp đóng vai trò là nguồn dữ liệu

Khi làm việc với các đối tượng lớn như đối tượng JSON hoặc bitmap, bạn cần làm việc với đối tượng File và xử lý các luồng chuyển đổi.

Để tìm hiểu thêm về cách làm việc với dung lượng lưu trữ của tệp, hãy xem trang Tổng quan về bộ nhớ.

Lên lịch tác vụ bằng WorkManager

Giả sử một yêu cầu mới khác được đưa ra cho ứng dụng News (Tin tức): ứng dụng phải cung cấp cho người dùng lựa chọn tìm nạp tin tức mới nhất thường xuyên và tự động miễn là thiết bị đang sạc và kết nối với một mạng không đo lượng dữ liệu. Yêu cầu đó khiến thao tác này trở thành một thao tác do hoạt động kinh doanh điều hướng. Yêu cầu này được đưa ra để ngay cả khi thiết bị không có kết nối khi người dùng mở ứng dụng, thì người dùng vẫn có thể xem tin tức gần đây.

WorkManager giúp bạn dễ dàng lên lịch trình công việc không đồng bộ và đáng tin cậy, đồng thời có thể đảm nhận việc quản lý giới hạn. Đây là thư viện đề xuất cho tính năng làm việc liên tục. Để thực hiện tác vụ được xác định ở trên, một lớp Worker sẽ được tạo: RefreshLatestNewsWorker. Lớp này sẽ lấy NewsRepository làm một phần phụ thuộc để tìm nạp tin tức mới nhất rồi lưu vào bộ nhớ đệm.

class RefreshLatestNewsWorker(
    private val newsRepository: NewsRepository,
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = try {
        newsRepository.refreshLatestNews()
        Result.success()
    } catch (error: Throwable) {
        Result.failure()
    }
}

Logic nghiệp vụ cho loại tác vụ này phải được đóng gói trong lớp riêng và được coi là một nguồn dữ liệu riêng biệt. Sau đó, WorkManager sẽ chỉ chịu trách nhiệm về việc đảm bảo công việc được thực thi trên một luồng ở chế độ nền khi đáp ứng được tất cả các quy tắc giới hạn. Bằng cách tuân thủ mẫu này, bạn có thể nhanh chóng hoán đổi các nội dung triển khai trên các môi trường khác nhau nếu cần.

Trong ví dụ này, tác vụ liên quan đến tin tức này phải được gọi từ NewsRepository. Thao tác này sẽ lấy một nguồn dữ liệu mới làm phần phụ thuộc: NewsTasksDataSource, được triển khai như sau:

private const val REFRESH_RATE_HOURS = 4L
private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"
private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"

class NewsTasksDataSource(
    private val workManager: WorkManager
) {
    fun fetchNewsPeriodically() {
        val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(
            REFRESH_RATE_HOURS, TimeUnit.HOURS
        ).setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)
                .setRequiresCharging(true)
                .build()
        )
            .addTag(TAG_FETCH_LATEST_NEWS)

        workManager.enqueueUniquePeriodicWork(
            FETCH_LATEST_NEWS_TASK,
            ExistingPeriodicWorkPolicy.KEEP,
            fetchNewsRequest.build()
        )
    }

    fun cancelFetchingNewsPeriodically() {
        workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)
    }
}

Các loại lớp này được đặt tên theo dữ liệu mà các lớp này chịu trách nhiệm, ví dụ: NewsTasksDataSource hoặc PaymentsTasksDataSource. Tất cả các tác vụ liên quan đến một loại dữ liệu cụ thể phải được gói gọn trong cùng một lớp.

Nếu tác vụ cần được kích hoạt khi khởi động ứng dụng, bạn nên kích hoạt yêu cầu WorkManager bằng cách sử dụng thư viện Khởi động ứng dụng. Thư viện sẽ gọi kho lưu trữ từ Initializer.

Để tìm hiểu thêm về cách làm việc với các API WorkManager, hãy xem hướng dẫn về WorkManager.

Thử nghiệm

Các phương pháp hay nhất về chèn phần phụ thuộc giúp ích khi thử nghiệm ứng dụng của bạn. Việc dựa vào giao diện của các lớp giao tiếp với các tài nguyên bên ngoài cũng rất hữu ích. Khi thử nghiệm một đơn vị, bạn có thể chèn các phiên bản phần phụ thuộc giả mạo để làm cho thử nghiệm trở nên tất định và đáng tin cậy.

Thử nghiệm đơn vị

Hướng dẫn thử nghiệm chung áp dụng khi thử nghiệm lớp dữ liệu. Đối với thử nghiệm đơn vị, hãy sử dụng các đối tượng thực khi cần thiết và giả mạo bất kỳ phần phụ thuộc nào với các nguồn bên ngoài như đọc từ một tệp hoặc đọc từ mạng.

Thử nghiệm tích hợp

Các thử nghiệm tích hợp truy cập vào các nguồn bên ngoài có xu hướng ít mang tính quyết định hơn vì các thử nghiệm này cần chạy trên một thiết bị thực. Bạn nên thực hiện các thử nghiệm đó trong môi trường được kiểm soát để làm cho các thử nghiệm tích hợp trở nên đáng tin cậy hơn.

Đối với cơ sở dữ liệu, Room cho phép tạo một cơ sở dữ liệu trong bộ nhớ mà bạn có thể toàn quyền kiểm soát trong các bài kiểm thử của mình. Để tìm hiểu thêm, hãy xem trang Kiểm tra và gỡ lỗi cho cơ sở dữ liệu của bạn.

Đối với hoạt động kết nối mạng, có các thư viện phổ biến như WireMock hoặc MockWebServer cho phép bạn thực hiện các lệnh gọi HTTP và HTTPS giả mạo, cũng như xác minh rằng các yêu cầu được thực hiện dưới dạng như dự kiến.

Mẫu

Các mẫu sau đây của Google minh hoạ cách sử dụng lớp dữ liệu. Hãy khám phá những mẫu đó để xem hướng dẫn này trong thực tế: