Xây dựng ứng dụng có chế độ ngoại tuyến

Ứng dụng có chế độ ngoại tuyến là một ứng dụng có thể thực hiện tất cả hoặc một nhóm nhỏ chức năng cốt lõi của ứng dụng mà không cần truy cập vào mạng Internet. Tức là ứng dụng nêu trên có thể thực hiện một số hoặc tất cả logic kinh doanh khi không có mạng.

Hãy cân nhắc xây dựng một ứng dụng có chế độ ngoại tuyến bắt đầu ngay trong lớp dữ liệu để có thể truy cập vào dữ liệu ứng dụng và logic kinh doanh. Thỉnh thoảng, ứng dụng có thể cần làm mới dữ liệu này từ các nguồn bên ngoài của thiết bị. Để làm mới dữ liệu, ứng dụng có thể phải gọi tài nguyên mạng để cập nhật thông tin mới nhất.

Không phải lúc nào mạng cũng hoạt động ổn định. Thiết bị thường phải trải qua giai đoạn kết nối mạng chậm hoặc bị gián đoạn. Người dùng có thể gặp các vấn đề sau đây:

  • Băng thông Internet bị hạn chế
  • Tình trạng gián đoạn kết nối tạm thời khi ở trong thang máy hoặc đường hầm.
  • Thỉnh thoảng mới truy cập được vào dữ liệu. Ví dụ: máy tính bảng chỉ dùng Wi-Fi.

Bất kể lý do là gì, thông thường, ứng dụng có thể hoạt động đúng cách trong những trường hợp này. Để đảm bảo ứng dụng hoạt động đúng cách khi không có mạng, ứng dụng đó phải:

  • Sử dụng được mà không cần có kết nối mạng đáng tin cậy.
  • Cung cấp ngay dữ liệu cục bộ cho người dùng thay vì đợi lệnh gọi mạng đầu tiên hoàn tất hoặc trả về trạng thái không thành công.
  • Tìm nạp dữ liệu theo cách nhận biết được trạng thái dữ liệu cũng như trạng thái pin. Ví dụ: chỉ yêu cầu tìm nạp dữ liệu trong các điều kiện tối ưu, chẳng hạn như khi sạc hoặc có kết nối Wi-Fi.

Thường thì ứng dụng nào có thể đáp ứng các tiêu chí nêu trên sẽ được gọi là ứng dụng có chế độ ngoại tuyến.

Thiết kế ứng dụng có chế độ ngoại tuyến

Khi thiết kế một ứng dụng có chế độ ngoại tuyến, bạn nên bắt đầu với lớp dữ liệu và hai thao tác chính mà bạn có thể thực hiện đối với dữ liệu ứng dụng:

  • Đọc: Truy xuất dữ liệu để các phần khác của ứng dụng có thể dùng, chẳng hạn như hiển thị thông tin cho người dùng.
  • Viết: Giữ nguyên hoạt động đầu vào của người dùng để truy xuất sau.

Kho lưu trữ ở trong lớp dữ liệu có nhiệm vụ kết hợp các nguồn dữ liệu với nhau để cung cấp dữ liệu ứng dụng. Trong ứng dụng có chế độ ngoại tuyến, phải có ít nhất một nguồn dữ liệu không cần quyền truy cập mạng để thực hiện các tác vụ quan trọng nhất. Một trong những nhiệm vụ quan trọng này là đọc dữ liệu.

Dữ liệu về mô hình trong ứng dụng có chế độ ngoại tuyến

Ứng dụng có chế độ ngoại tuyến có tối thiểu 2 nguồn dữ liệu cho mỗi kho lưu trữ sử dụng tài nguyên mạng:

  • Nguồn dữ liệu cục bộ
  • Nguồn dữ liệu mạng
Lớp dữ liệu có chế độ ngoại tuyến bao gồm cả nguồn dữ liệu cục bộ và nguồn dữ liệu mạng
Hình 1: Kho lưu trữ có chế độ ngoại tuyến

Nguồn dữ liệu cục bộ

Nguồn dữ liệu cục bộ là nguồn đáng tin cậy chuẩn cho ứng dụng. Đây phải là nguồn độc quyền của mọi dữ liệu được các lớp cao hơn của ứng dụng đọc. Điều này giúp đảm bảo tính nhất quán của dữ liệu giữa các trạng thái kết nối. Nguồn dữ liệu cục bộ thường do bộ nhớ sao lưu sẽ được lưu vào ổ đĩa. Sau đây là một số phương tiện phổ biến giúp lưu dữ liệu vào ổ đĩa:

  • Các nguồn dữ liệu có cấu trúc, chẳng hạn: cơ sở dữ liệu quan hệ như Room.
  • Nguồn dữ liệu không có cấu trúc. Ví dụ: vùng đệm giao thức có Datastore.
  • Tệp đơn giản

Nguồn dữ liệu mạng

Nguồn dữ liệu mạng là trạng thái thực tế của ứng dụng. Nguồn dữ liệu cục bộ sẽ đồng bộ hoá tốt nhất với nguồn dữ liệu mạng. Nguồn dữ liệu cục bộ cũng có thể bị trễ so với nguồn dữ liệu mạng. Lúc đó, ứng dụng cần được cập nhật khi có kết nối mạng trở lại. Ngược lại, nguồn dữ liệu mạng có thể bị trễ so với nguồn dữ liệu cục bộ cho đến tận lúc được ứng dụng cập nhật khi có kết nối trở lại. Các lớp miền và lớp giao diện người dùng của ứng dụng tuyệt đối không cần liên kết trực tiếp với lớp mạng. Trách nhiệm của repository lưu trữ là để giao tiếp và sử dụng lớp mạng này nhằm cập nhật nguồn dữ liệu cục bộ.

Tài nguyên hiển thị

Về cơ bản, cách thức ứng dụng đọc và ghi vào nguồn dữ liệu cục bộ và nguồn dữ liệu mạng có thể khác nhau. Việc truy vấn một nguồn dữ liệu cục bộ có thể diễn ra một cách nhanh chóng và linh hoạt, chẳng hạn như khi sử dụng truy vấn SQL. Ngược lại, việc truy vấn các nguồn dữ liệu mạng có thể bị chậm trễ và hạn chế, chẳng hạn như khi truy cập tăng dần vào tài nguyên RESTful theo mã nhận dạng. Do đó, mỗi nguồn dữ liệu thường cần có mô hình đại diện riêng cho dữ liệu mà nguồn dữ liệu đó cung cấp. Đó là lý do mà nguồn dữ liệu cục bộ và nguồn dữ liệu mạng nói trên có thể có các mô hình riêng.

Cấu trúc thư mục ở dưới đây sẽ minh hoạ khái niệm này theo cách trực quan. AuthorEntity đại diện cho một tác giả được đọc từ cơ sở dữ liệu cục bộ của ứng dụng và NetworkAuthor đại diện cho một tác giả được chuyển đổi tuần tự qua mạng:

data/
├─ local/
│ ├─ entities/
│ │ ├─ AuthorEntity
│ ├─ dao/
│ ├─ NiADatabase
├─ network/
│ ├─ NiANetwork
│ ├─ models/
│ │ ├─ NetworkAuthor
├─ model/
│ ├─ Author
├─ repository/

Sau đây là thông tin chi tiết về AuthorEntityNetworkAuthor:

/**
 * Network representation of [Author]
 */
@Serializable
data class NetworkAuthor(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

/**
 * Defines an author for either an [EpisodeEntity] or [NewsResourceEntity].
 * It has a many-to-many relationship with both entities
 */
@Entity(tableName = "authors")
data class AuthorEntity(
    @PrimaryKey
    val id: String,
    val name: String,
    @ColumnInfo(name = "image_url")
    val imageUrl: String,
    @ColumnInfo(defaultValue = "")
    val twitter: String,
    @ColumnInfo(name = "medium_page", defaultValue = "")
    val mediumPage: String,
    @ColumnInfo(defaultValue = "")
    val bio: String,
)

Bạn nên giữ cả AuthorEntityNetworkAuthor ở bên trong lớp dữ liệu và hiển thị loại thứ ba cho các lớp bên ngoài sử dụng. Việc này giúp bảo vệ lớp bên ngoài khỏi các thay đổi nhỏ trong nguồn dữ liệu cục bộ và nguồn dữ liệu mạng mà về cơ bản không làm thay đổi hành vi của ứng dụng. Xem ví dụ minh hoạ qua đoạn mã sau:

/**
 * External data layer representation of a "Now in Android" Author
 */
data class Author(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

Sau đó, mô hình mạng có thể xác định một phương thức mở rộng để chuyển đổi thành mô hình cục bộ. Tương tự như vậy, mô hình cục bộ cũng có một phương thức để chuyển đổi thành mô hình đại diện bên ngoài như minh hoạ ở bên dưới:

/**
 * Converts the network model to the local model for persisting
 * by the local data source
 */
fun NetworkAuthor.asEntity() = AuthorEntity(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

/**
 * Converts the local model to the external model for use
 * by layers external to the data layer
 */
fun AuthorEntity.asExternalModel() = Author(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

Hoạt động đọc

Đọc là thao tác cơ bản đối với dữ liệu ứng dụng trong một ứng dụng có chế độ ngoại tuyến. Do đó, bạn phải đảm bảo ứng dụng của mình có thể đọc và hiển thị dữ liệu ngay khi có dữ liệu mới. Ứng dụng nào làm được việc đó sẽ là một ứng dụng có tính phản ứng vì chúng hiển thị các API đọc chứa các loại đối tượng phát ra dữ liệu.

Trong đoạn mã dưới đây, OfflineFirstTopicRepository trả về Flows cho tất cả các API đọc của nó. Việc này cho phép ứng dụng cập nhật trình đọc khi nhận được thông tin cập nhật từ nguồn dữ liệu mạng. Nói cách khác, ứng dụng này cho phép thay đổi quá trình đẩy OfflineFirstTopicRepository khi nguồn dữ liệu cục bộ của ứng dụng không hợp lệ. Do đó, cần chuẩn bị từng trình đọc cho OfflineFirstTopicRepository để xử lý các thay đổi về dữ liệu. Trình đọc có thể được kích hoạt khi ứng dụng khôi phục kết nối mạng. Hơn nữa, OfflineFirstTopicRepository sẽ đọc dữ liệu trực tiếp từ nguồn dữ liệu cục bộ. Ứng dụng chỉ có thể thông báo cho trình đọc về các thay đổi đối với dữ liệu bằng cách cập nhật nguồn dữ liệu cục bộ trước tiên.

class OfflineFirstTopicsRepository(
    private val topicDao: TopicDao,
    private val network: NiaNetworkDataSource,
) : TopicsRepository {

    override fun getTopicsStream(): Flow<List<Topic>> =
        topicDao.getTopicEntitiesStream()
            .map { it.map(TopicEntity::asExternalModel) }
}

Chiến lược xử lý lỗi

Ứng dụng có chế độ ngoại tuyến đưa ra nhiều cách riêng để xử lý lỗi, tuỳ thuộc vào nguồn dữ liệu xảy ra lỗi. Các tiểu mục sau đây sẽ nêu ra những ý chính của những chiến lược này.

Nguồn dữ liệu cục bộ

Rất hiếm khi xảy ra lỗi khi đọc từ nguồn dữ liệu cục bộ. Để trình đọc không bị lỗi, hãy sử dụng toán tử catch trên Flows, từ đó trình đọc sẽ thu thập dữ liệu.

Sau đây là cách sử dụng toán tử catch trong ViewModel:

class AuthorViewModel(
    authorsRepository: AuthorsRepository,
    ...
) : ViewModel() {
   private val authorId: String = ...

   // Observe author information
    private val authorStream: Flow<Author> =
        authorsRepository.getAuthorStream(
            id = authorId
        )
        .catch { emit(Author.empty()) }
}

Nguồn dữ liệu mạng

Nếu xảy ra lỗi khi đọc dữ liệu từ một nguồn dữ liệu mạng, ứng dụng sẽ cần sử dụng phương pháp heuristic (phỏng đoán) để thử tìm nạp lại dữ liệu. Sau đây là các phương pháp heuristic (phỏng đoán) phổ biến:

Thuật toán thời gian đợi luỹ thừa

Trong thuật toán thời gian đợi luỹ thừa, ứng dụng sẽ tiếp tục tìm cách đọc từ nguồn dữ liệu mạng với khoảng thời gian tăng dần cho đến khi thành công, hoặc cho đến khi có các điều kiện khác quy định rằng ứng dụng nên dừng.

Đọc dữ liệu bằng thuật toán thời gian đợi luỹ thừa
Hình 2: Đọc dữ liệu bằng thuật toán thời gian đợi luỹ thừa

Sau đây là các tiêu chí để đánh giá xem ứng dụng có nên duy trì thuật toán thời gian đợi hay không:

  • Loại lỗi được nguồn dữ liệu mạng chỉ ra. Ví dụ: bạn nên thử lại các lệnh gọi mạng trả về lỗi cho biết tình trạng mất kết nối. Ngược lại, bạn không nên thử lại các yêu cầu HTTP không được phép cho đến khi có thông tin xác thực phù hợp.
  • Số lần thử lại tối đa cho phép.
Giám sát kết nối mạng

Trong phương pháp này, các yêu cầu đọc sẽ được đưa vào hàng đợi cho đến khi ứng dụng chắc chắn rằng nó có thể kết nối với nguồn dữ liệu mạng. Sau khi thiết lập kết nối, yêu cầu đọc sẽ được đưa ra khỏi hàng đợi, dữ liệu sẽ được đọc và nguồn dữ liệu cục bộ sẽ được cập nhật. Trên Android, hàng đợi này có thể được duy trì bằng cơ sở dữ liệu Room và được thực hiện dưới hình thức công việc diễn ra liên tục bằng WorkManager.

Đọc dữ liệu bằng hàng đợi và trình giám sát mạng
Hình 3: Hàng đợi đọc có chức năng giám sát mạng

Hoạt động ghi

Mặc dù cách đề xuất để đọc dữ liệu trong ứng dụng có chế độ ngoại tuyến là sử dụng các loại đối tượng phát ra dữ liệu, nhưng tương đương với các API ghi là các API không đồng bộ, chẳng hạn như các hàm tạm ngưng. Điều này giúp chặn luồng giao diện người dùng và giúp xử lý lỗi vì việc ghi trong các ứng dụng có chế độ ngoại tuyến có thể không thành công khi vượt qua ranh giới mạng.

interface UserDataRepository {
    /**
     * Updates the bookmarked status for a news resource
     */
    suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
}

Trong đoạn mã ở trên, API không đồng bộ được lựa chọn là Coroutines khiến cho phương thức nêu trên sẽ tạm ngưng.

Chiến lược ghi

Khi ghi dữ liệu trong các ứng dụng có chế độ ngoại tuyến, cần cân nhắc sử dụng ba chiến lược. Chiến lược bạn lựa chọn sẽ tuỳ thuộc vào loại dữ liệu đang được ghi và các yêu cầu của ứng dụng:

Hoạt động ghi chỉ dành cho môi trường trực tuyến

Tìm cách ghi dữ liệu qua ranh giới mạng. Nếu thành công, hãy cập nhật nguồn dữ liệu cục bộ. Nếu không, hãy gửi một ngoại lệ và chuyển ngoại lệ đó đến phương thức gọi để phản hồi đúng cách.

Hoạt động ghi chỉ dành cho môi trường trực tuyến
Hình 4: Hoạt động ghi chỉ dành cho môi trường trực tuyến

Chiến lược này thường được dùng cho các giao dịch ghi phải diễn ra trực tuyến trong thời gian gần như thực. Ví dụ: giao dịch chuyển khoản ngân hàng. Vì việc ghi có thể không thực hiện được, nên thông thường, bạn cần phải thông báo cho người dùng rằng việc ghi không thành công hoặc ngăn người dùng cố ghi dữ liệu ngay từ đầu. Sau đây là một số chiến lược mà bạn có thể áp dụng cho những trường hợp này:

  • Nếu ứng dụng yêu cầu kết nối Internet để ghi dữ liệu, thì ứng dụng có thể chọn không hiển thị giao diện người dùng đến người dùng. Điều này cho phép họ ghi dữ liệu hoặc ít nhất là tắt chế độ đó.
  • Bạn có thể sử dụng thông báo bật lên mà người dùng không thể đóng hoặc lời nhắc tạm thời, để thông báo cho người dùng rằng họ đang không có kết nối mạng.

Số hoạt động ghi ở trong hàng đợi

Khi bạn muốn ghi một đối tượng, hãy chèn đối tượng đó vào một hàng đợi. Tiếp tục loại bỏ hàng đợi đó bằng thuật toán thời gian đợi luỹ thừa khi ứng dụng có mạng trở lại. Trên Android, việc loại bỏ một hàng đợi ngoại tuyến là công việc diễn ra liên tục thường do WorkManager đảm nhận.

Hàng đợi ghi cùng với số lần thử lại
Hình 5: Hàng đợi ghi với số lần thử lại

Bạn nên chọn phương pháp này nếu:

  • Bạn không cần phải ghi dữ liệu vào mạng.
  • Giao dịch không có giới hạn về thời gian.
  • Bạn không cần phải thông báo cho người dùng nếu thao tác đó không thành công.

Những trường hợp sử dụng phương pháp này bao gồm hoạt động ghi nhật ký và các sự kiện phân tích.

Hoạt động ghi Lazy

Trước tiên, hãy ghi vào nguồn dữ liệu cục bộ rồi đưa hoạt động ghi vào hàng đợi để thông báo cho mạng trong thời gian sớm nhất có thể. Đây không phải là một vấn đề nhỏ vì có thể có xung đột giữa nguồn dữ liệu mạng và nguồn dữ liệu cục bộ khi ứng dụng có kết nối mạng trở lại. Phần tiếp theo sẽ cung cấp thêm thông tin chi tiết về cách giải quyết xung đột.

Hoạt động ghi Lazy có tính năng giám sát mạng
Hình 6: Hoạt động ghi Lazy

Phương pháp này phù hợp khi dữ liệu có vai trò thiết yếu với ứng dụng. Ví dụ: trong ứng dụng có chế độ ngoại tuyến về danh sách việc cần làm, mọi công việc mà người dùng thêm vào khi không có mạng nhất thiết phải được lưu trữ cục bộ để tránh nguy cơ mất dữ liệu.

Đồng bộ hoá và giải quyết xung đột

Khi một ứng dụng có chế độ ngoại tuyến khôi phục khả năng kết nối, ứng dụng đó cần phải điều chỉnh dữ liệu trong nguồn dữ liệu cục bộ so với nguồn dữ liệu mạng. Quá trình này gọi là đồng bộ hoá. Có hai cách chính để ứng dụng có thể đồng bộ hoá với nguồn dữ liệu mạng của nó:

  • Đồng bộ hoá theo phương thức kéo
  • Đồng bộ hoá theo phương thức đẩy

Đồng bộ hoá theo phương thức kéo

Trong quá trình đồng bộ hoá dựa trên phương thức kéo, ứng dụng sẽ kết nối với mạng để đọc dữ liệu ứng dụng mới nhất theo yêu cầu. Một phương pháp heuristic (phỏng đoán) phổ biến cho phương pháp này là dựa trên thành phần điều hướng, trong đó ứng dụng chỉ tìm nạp dữ liệu ngay trước khi ứng dụng hiển thị dữ liệu cho người dùng.

Cách tiếp cận này hoạt động hiệu quả nhất khi ứng dụng dự kiến khoảng thời gian không có kết nối mạng là từ ngắn đến trung bình. Đó là do việc làm mới dữ liệu mang tính cơ hội và việc không có kết nối trong thời gian dài làm tăng khả năng người dùng tìm cách truy cập vào các đích đến của ứng dụng bằng bộ nhớ đệm đã lỗi thời hoặc trống.

Đồng bộ hoá dựa trên phương thức kéo
Hình 7: Đồng bộ hoá dựa trên phương thức kéo: Thiết bị A chỉ truy cập vào tài nguyên của màn hình A và B, trong khi thiết bị B chỉ truy cập vào tài nguyên của màn hình B, C và D

Hãy xem xét trường hợp một ứng dụng dùng mã thông báo trang để tìm nạp các mục trong danh sách cuộn vô tận cho một màn hình cụ thể. Quá trình triển khai có thể trì hoãn việc liên hệ với mạng, lưu dữ liệu vào nguồn dữ liệu cục bộ rồi đọc từ nguồn dữ liệu cục bộ đó để hiển thị lại thông tin cho người dùng. Trong trường hợp không có kết nối mạng, kho lưu trữ chỉ có thể yêu cầu dữ liệu từ nguồn dữ liệu cục bộ. Đây là mẫu mà Thư viện Jetpack Paging sử dụng với API RemoteMediator.

class FeedRepository(...) {

    fun feedPagingSource(): PagingSource<FeedItem> { ... }
}

class FeedViewModel(
    private val repository: FeedRepository
) : ViewModel() {
    private val pager = Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
        ),
        remoteMediator = FeedRemoteMediator(...),
        pagingSourceFactory = feedRepository::feedPagingSource
    )

    val feedPagingData = pager.flow
}

Bảng dưới đây tóm tắt các ưu và nhược điểm của tính năng đồng bộ hoá dựa trên phương thức kéo:

Ưu điểm Nhược điểm
Tương đối dễ triển khai. Dễ dàng sử dụng khi có nhiều dữ liệu. Điều này là do các lượt truy cập lặp lại vào một đích đến điều hướng sẽ kích hoạt việc tìm nạp lại không cần thiết thông tin không thay đổi. Bạn có thể giảm thiểu tình trạng này bằng việc lưu vào bộ nhớ đệm đúng cách. Việc này được thực hiện trong lớp giao diện người dùng qua toán tử cachedIn hoặc trong lớp mạng bằng bộ nhớ đệm HTTP.
Khi đó, hệ thống sẽ tuyệt đối không tìm nạp dữ liệu không cần thiết. Không điều chỉnh được theo tỷ lệ phù hợp với dữ liệu quan hệ vì mô hình được kéo ra phải là mô hình tự cung cấp. Nếu mô hình đang được đồng bộ hoá phụ thuộc vào các mô hình khác cần tìm nạp để tự điền, thì vấn đề sử dụng nhiều dữ liệu được đề cập ở trên sẽ trở nên quan trọng hơn nữa. Hơn nữa, điều này có thể tạo ra phần phụ thuộc giữa các kho lưu trữ của mô hình mẹ và các kho lưu trữ của mô hình lồng nhau.

Đồng bộ hoá theo phương thức đẩy

Trong quá trình đồng bộ hoá theo phương thức đẩy, nguồn dữ liệu cục bộ sẽ tìm cách mô phỏng một tập hợp bản sao của nguồn dữ liệu mạng một cách tốt nhất có thể. Nguồn dữ liệu này sẽ chủ động tìm nạp một lượng dữ liệu thích hợp trong lần khởi động đầu tiên để thiết lập đường cơ sở, sau đó, đường cơ sở đó sẽ dựa vào thông báo từ máy chủ để cảnh báo cho nguồn dữ liệu khi dữ liệu đó đã lỗi thời.

Đồng bộ hoá theo phương thức đẩy
Hình 8: Đồng bộ hoá theo phương thức đẩy: Mạng sẽ thông báo cho ứng dụng khi dữ liệu thay đổi và ứng dụng sẽ phản hồi bằng cách tìm nạp dữ liệu đã thay đổi

Khi nhận được thông báo về tình trạng lỗi thời, ứng dụng sẽ liên hệ với mạng để chỉ cập nhật dữ liệu được đánh dấu là lỗi thời. Việc này sẽ do Repository thực hiện, nhằm liên hệ với nguồn dữ liệu mạng và duy trì dữ liệu được tìm nạp cho nguồn dữ liệu cục bộ. Vì kho lưu trữ hiển thị dữ liệu của nó với các loại đối tượng phát ra dữ liệu, nên trình đọc sẽ được thông báo về mọi thay đổi.

class UserDataRepository(...) {

    suspend fun synchronize() {
        val userData = networkDataSource.fetchUserData()
        localDataSource.saveUserData(userData)
    }
}

Trong phương pháp này, ứng dụng ít phụ thuộc hơn vào nguồn dữ liệu mạng và có thể hoạt động mà không cần đến nguồn dữ liệu mạng trong một khoảng thời gian dài. Ứng dụng sẽ cung cấp cả quyền đọc và quyền ghi khi không có kết nối mạng vì hệ thống giả định rằng ứng dụng này có thông tin mới nhất từ nguồn dữ liệu mạng cục bộ.

Bảng dưới đây tóm tắt các ưu và nhược điểm của tính năng đồng bộ hoá dựa trên phương thức đẩy:

Ưu điểm Nhược điểm
Ứng dụng có thể duy trì trạng thái không có mạng vĩnh viễn. Lượng dữ liệu về quá trình lập phiên bản để giải quyết xung đột là không hề nhỏ.
Sử dụng dữ liệu ở mức tối thiểu. Ứng dụng chỉ tìm nạp dữ liệu đã thay đổi. Bạn cần lưu ý đến việc ghi trong quá trình đồng bộ hoá.
Thích hợp với dữ liệu quan hệ. Mỗi kho lưu trữ chỉ chịu trách nhiệm tìm nạp dữ liệu cho mô hình mà kho lưu trữ đó hỗ trợ. Nguồn dữ liệu mạng cần hỗ trợ cho quá trình đồng bộ hoá.

Phương pháp đồng bộ hoá kết hợp

Một số ứng dụng sử dụng phương pháp kết hợp dựa trên phương thức kéo hoặc đẩy tuỳ thuộc vào dữ liệu. Ví dụ: Một ứng dụng mạng xã hội có thể sử dụng tính năng đồng bộ hoá dựa trên phương thức kéo để tìm nạp nguồn cấp dữ liệu sau đây của người dùng theo yêu cầu do tần suất cập nhật nguồn cấp dữ liệu ở mức cao. Ứng dụng đó cũng có thể chọn sử dụng tính năng đồng bộ hoá dựa trên phương thức đẩy đối với dữ liệu về người dùng đã đăng nhập, chẳng hạn như tên người dùng, ảnh hồ sơ và nhiều thông tin khác.

Cuối cùng, lựa chọn đồng bộ hoá ngoại tuyến sẽ phụ thuộc vào các yêu cầu về sản phẩm và cơ sở hạ tầng kỹ thuật có sẵn.

Giải quyết xung đột

Nếu ứng dụng ghi dữ liệu vào nguồn dữ liệu cục bộ khi không có kết nối mạng và nguồn dữ liệu này không khớp với nguồn dữ liệu mạng, thì bạn phải giải quyết xung đột đã xảy ra này trước khi quá trình đồng bộ hoá có thể diễn ra.

Thông thường, việc giải quyết xung đột sẽ yêu cầu tạo phiên bản. Ứng dụng sẽ cần thực hiện một số hoạt động ghi chép để theo dõi thời điểm có sự thay đổi. Việc này giúp ứng dụng chuyển siêu dữ liệu đến nguồn dữ liệu mạng. Sau đó, nguồn dữ liệu mạng sẽ có trách nhiệm cung cấp một nguồn hoàn toàn tin cậy. Bạn có thể cân nhắc nhiều chiến lược để giải quyết xung đột tuỳ thuộc vào nhu cầu của ứng dụng. Đối với những ứng dụng dành cho thiết bị di động, phương pháp phổ biến là "chọn lượt ghi sau cùng".

Chọn lượt ghi sau cùng

Trong phương pháp này, các thiết bị sẽ đính kèm siêu dữ liệu về dấu thời gian vào dữ liệu mà thiết bị ghi vào mạng. Khi nhận được dữ liệu, nguồn dữ liệu mạng sẽ loại bỏ mọi dữ liệu có trạng thái cũ hơn trạng thái hiện tại trong khi chấp nhận những dữ liệu có trạng thái mới hơn trạng thái hiện tại.

Giải quyết xung đột với phương pháp chọn lượt ghi sau cùng
Hình 9: "Chọn lượt ghi sau cùng" Nguồn đáng tin cậy cho dữ liệu được xác định theo đối tượng sau cùng ghi dữ liệu

Ở trên, cả hai thiết bị đều không kết nối mạng và bước đầu đang trong quá trình đồng bộ hoá với nguồn dữ liệu mạng. Khi không có mạng, cả hai thiết bị đều ghi dữ liệu theo cách cục bộ và theo dõi thời gian chúng ghi dữ liệu. Khi cả hai thiết bị đều kết nối mạng trở lại và đồng bộ hoá với nguồn dữ liệu mạng, mạng sẽ giải quyết xung đột bằng cách duy trì dữ liệu từ thiết bị B vì thiết bị này ghi dữ liệu sau thiết bị A.

WorkManager trong các ứng dụng có chế độ ngoại tuyến

Trong các chiến lược liên quan đến cả thao tác đọc và thao tác ghi được đề cập ở trên, có hai tiện ích phổ biến:

  • Hàng đợi
    • Lượt đọc: Dùng để trì hoãn việc đọc cho đến khi có kết nối mạng.
    • Lượt ghi: Dùng để trì hoãn việc ghi cho đến khi có kết nối mạng và đưa lại lượt ghi vào hàng đợi để thử lại.
  • Trình giám sát kết nối mạng
    • Lượt đọc: Được dùng làm tín hiệu để loại bỏ hàng đợi đọc khi ứng dụng được kết nối và để đồng bộ hoá
    • Lượt ghi: Được dùng làm tín hiệu để loại bỏ hàng đợi ghi khi ứng dụng được kết nối và để đồng bộ hoá

Cả hai trường hợp đều là ví dụ về công việc liên tụcWorkManager vượt trội. Ví dụ: trong ứng dụng mẫu Now in Android, WorkManager được dùng làm cả danh sách chờ đọc và trình giám sát mạng khi đồng bộ hoá nguồn dữ liệu cục bộ. Khi khởi động, ứng dụng sẽ thực hiện những thao tác sau đây:

  1. Thêm công việc đồng bộ hoá lượt đọc vào hàng đợi để đảm bảo có sự cân đối giữa nguồn dữ liệu cục bộ và nguồn dữ liệu mạng.
  2. Loại bỏ hàng đợi đồng bộ hoá lượt đọc và bắt đầu quá trình đồng bộ hoá khi ứng dụng có kết nối mạng.
  3. Thực hiện việc đọc từ nguồn dữ liệu mạng bằng thuật toán thời gian đợi luỹ thừa.
  4. Lưu kết quả của việc đọc vào nguồn dữ liệu cục bộ để giải quyết mọi xung đột có thể xảy ra.
  5. Hiển thị dữ liệu từ nguồn dữ liệu cục bộ để các lớp khác của ứng dụng có thể dùng.

Phần nói ở trên được minh hoạ qua sơ đồ sau đây:

Quá trình đồng bộ hoá dữ liệu trong ứng dụng Now in Android
Hình 10: Quá trình đồng bộ hoá dữ liệu trong ứng dụng Now in Android

Thêm công việc đồng bộ hoá vào hàng đợi bằng WorkManager thông qua việc chỉ định công việc đó là công việc duy nhấtKEEP ExistingWorkPolicy:

class SyncInitializer : Initializer<Sync> {
   override fun create(context: Context): Sync {
       WorkManager.getInstance(context).apply {
           // Queue sync on app startup and ensure only one
           // sync worker runs at any time
           enqueueUniqueWork(
               SyncWorkName,
               ExistingWorkPolicy.KEEP,
               SyncWorker.startUpSyncWork()
           )
       }
       return Sync
   }
}

Trong đó, SyncWorker.startupSyncWork() được xác định như sau:


/**
 Create a WorkRequest to call the SyncWorker using a DelegatingWorker.
 This allows for dependency injection into the SyncWorker in a different
 module than the app module without having to create a custom WorkManager
 configuration.
*/
fun startUpSyncWork() = OneTimeWorkRequestBuilder<DelegatingWorker>()
    // Run sync as expedited work if the app is able to.
    // If not, it runs as regular work.
   .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
   .setConstraints(SyncConstraints)
    // Delegate to the SyncWorker.
   .setInputData(SyncWorker::class.delegatedData())
   .build()

val SyncConstraints
   get() = Constraints.Builder()
       .setRequiredNetworkType(NetworkType.CONNECTED)
       .build()

Nói một cách cụ thể, Constraints do SyncConstraints xác định có yêu cầu rằng NetworkType phải là NetworkType.CONNECTED. Điều này có nghĩa là lệnh này sẽ chờ cho đến tận khi có mạng để chạy.

Sau khi có mạng, Worker sẽ loại bỏ hàng đợi công việc duy nhất do SyncWorkName chỉ định bằng cách uỷ quyền cho các phiên bản Repository thích hợp. Nếu quá trình đồng bộ hoá không thành công, thì phương thức doWork() sẽ trả về cùng với Result.retry(). WorkManager sẽ tự động thử lại quá trình đồng bộ hoá bằng thuật toán thời gian đợi luỹ thừa. Nếu không, WorkManager sẽ trả về trạng thái hoàn tất quá trình đồng bộ hoá Result.success().

class SyncWorker(...) : CoroutineWorker(appContext, workerParams), Synchronizer {

    override suspend fun doWork(): Result = withContext(ioDispatcher) {
        // First sync the repositories in parallel
        val syncedSuccessfully = awaitAll(
            async { topicRepository.sync() },
            async { authorsRepository.sync() },
            async { newsRepository.sync() },
        ).all { it }

        if (syncedSuccessfully) Result.success()
        else Result.retry()
    }
}

Mẫu

Các mẫu sau đây của Google minh hoạ các ứng dụng có chế độ ngoại tuyến. Hãy khám phá những mẫu đó để xem hướng dẫn này trong thực tế: