สร้างแอปที่ทำงานแบบออฟไลน์เป็นหลัก

แอปที่เน้นการใช้งานแบบออฟไลน์เป็นหลักคือแอปที่สามารถทำงานทั้งหมดหรือชุดย่อยที่สำคัญของฟังก์ชันหลักได้โดยไม่ต้องเข้าถึงอินเทอร์เน็ต กล่าวคือ สามารถใช้ตรรกะทางธุรกิจบางส่วนหรือทั้งหมดแบบออฟไลน์ได้

ข้อควรพิจารณาในการสร้างแอปที่ทำงานแบบออฟไลน์เป็นหลักจะเริ่มในชั้นข้อมูล ซึ่งให้สิทธิ์เข้าถึงข้อมูลแอปพลิเคชันและตรรกะทางธุรกิจ แอปอาจต้องรีเฟรชข้อมูลนี้จากแหล่งที่มาภายนอกอุปกรณ์เป็นครั้งคราว ซึ่งอาจต้องเรียกใช้ทรัพยากรเครือข่ายเพื่อให้เป็นข้อมูลล่าสุดเสมอ

เราไม่รับประกันความพร้อมใช้งานของเครือข่ายเสมอไป อุปกรณ์มักจะมีช่วงเวลาที่การเชื่อมต่อเครือข่ายไม่เสถียรหรือช้า ผู้ใช้อาจพบปัญหาต่อไปนี้

  • แบนด์วิดท์อินเทอร์เน็ตจำกัด
  • การเชื่อมต่อหยุดชะงักชั่วคราว เช่น เมื่ออยู่ในลิฟต์หรืออุโมงค์
  • การเข้าถึงข้อมูลเป็นครั้งคราว เช่น แท็บเล็ตที่ใช้ Wi-Fi เท่านั้น

ไม่ว่าจะด้วยเหตุผลใดก็ตาม แอปมักจะทำงานได้อย่างเพียงพอในสถานการณ์เหล่านี้ เพื่อให้มั่นใจว่าแอปทำงานได้อย่างถูกต้องแบบออฟไลน์ แอปต้องทำสิ่งต่อไปนี้ได้

  • ยังคงใช้งานได้แม้ไม่มีการเชื่อมต่อเครือข่ายที่เสถียร
  • แสดงข้อมูลผลิตภัณฑ์ในพื้นที่ให้ผู้ใช้เห็นทันทีแทนที่จะรอให้การเรียกใช้เครือข่ายครั้งแรกเสร็จสมบูรณ์หรือล้มเหลว
  • ดึงข้อมูลในลักษณะที่คำนึงถึงสถานะแบตเตอรี่และอินเทอร์เน็ต เช่น โดยการขอให้ดึงข้อมูลเฉพาะในสภาวะที่เหมาะสม เช่น เมื่อชาร์จหรือใช้ Wi-Fi

แอปที่ตรงตามเกณฑ์เหล่านี้มักเรียกว่าแอปที่เน้นการใช้งานแบบออฟไลน์

ออกแบบแอปที่เน้นการใช้งานแบบออฟไลน์เป็นหลัก

เมื่อออกแบบแอปที่เน้นการใช้งานแบบออฟไลน์ ให้เริ่มที่ชั้นข้อมูลและการดำเนินการหลัก 2 อย่างที่คุณทำกับข้อมูลแอปได้ ดังนี้

  • การอ่าน: การดึงข้อมูลเพื่อใช้โดยส่วนอื่นๆ ของแอป เช่น การแสดงข้อมูลแก่ผู้ใช้ ใน Compose คุณมักจะทำเช่นนี้ได้โดยการสังเกตสถานะ เมื่อ UI สังเกตแหล่งข้อมูลผลิตภัณฑ์ในพื้นที่เป็นสถานะ หน้าจอจะแสดงข้อมูลผลิตภัณฑ์ในพื้นที่ล่าสุดโดยอัตโนมัติ
  • การเขียน: การเก็บรักษาข้อมูลที่ผู้ใช้ป้อนไว้เพื่อเรียกดูในภายหลัง ใน Compose คุณมักจะทำได้โดยใช้เหตุการณ์และการดำเนินการที่ส่งจาก UI ไปยัง ViewModel

ที่เก็บข้อมูลในชั้นข้อมูลมีหน้าที่รวมแหล่งข้อมูลเพื่อจัดเตรียมข้อมูลแอป ในแอปที่เน้นการใช้งานแบบออฟไลน์เป็นหลัก ต้องมีแหล่งข้อมูลอย่างน้อย 1 แหล่งที่ไม่จำเป็นต้องเข้าถึงเครือข่ายเพื่อทำงานที่สำคัญที่สุด งานสำคัญอย่างหนึ่งคือการอ่านข้อมูล

จำลองข้อมูลในแอปที่เน้นการใช้งานแบบออฟไลน์

แอปที่เน้นการทำงานแบบออฟไลน์มีแหล่งข้อมูลอย่างน้อย 2 แหล่งสำหรับที่เก็บทุกแห่งที่ใช้ทรัพยากรเครือข่าย ดังนี้

  • แหล่งข้อมูลผลิตภัณฑ์ในพื้นที่
  • แหล่งข้อมูลเครือข่าย
ชั้นข้อมูลที่เน้นการทำงานแบบออฟไลน์ประกอบด้วยแหล่งข้อมูลทั้งในเครื่องและในเครือข่าย
รูปที่ 1: ที่เก็บข้อมูลแบบออฟไลน์เป็นหลัก

แหล่งข้อมูลผลิตภัณฑ์ในพื้นที่

แหล่งข้อมูลในเครื่องคือแหล่งข้อมูลที่เชื่อถือได้ตามรูปแบบบัญญัติสำหรับแอป โดยควรเป็นแหล่งข้อมูลเดียวสำหรับข้อมูลที่เลเยอร์ที่สูงกว่าของแอปอ่าน วิธีนี้ช่วยให้ข้อมูลสอดคล้องกันระหว่างสถานะการเชื่อมต่อ แหล่งข้อมูลผลิตภัณฑ์ในพื้นที่มักจะได้รับการสนับสนุนโดยพื้นที่เก็บข้อมูลที่ยังคงอยู่ในดิสก์ วิธีการทั่วไปในการเก็บข้อมูลไว้ในดิสก์มีดังนี้

  • แหล่งข้อมูลที่มีโครงสร้าง เช่น ฐานข้อมูลเชิงสัมพันธ์อย่าง Room
  • แหล่งข้อมูลที่ไม่มีโครงสร้าง เช่น บัฟเฟอร์โปรโตคอลที่มี DataStore
  • ไฟล์แบบง่าย

แหล่งข้อมูลเครือข่าย

แหล่งข้อมูลเครือข่ายคือสถานะจริงของแอปพลิเคชัน อย่างดีที่สุด แหล่งข้อมูลผลิตภัณฑ์ในพื้นที่จะซิงค์กับแหล่งข้อมูลเครือข่าย แหล่งข้อมูลผลิตภัณฑ์ในพื้นที่อาจล้าหลังกว่าแหล่งข้อมูลในเครือข่าย ซึ่งในกรณีนี้จะต้องอัปเดตแอปเมื่อกลับมาออนไลน์อีกครั้ง ในทางกลับกัน แหล่งข้อมูลเครือข่ายอาจล้าหลังแหล่งข้อมูลผลิตภัณฑ์ในพื้นที่จนกว่าแอปจะอัปเดตได้เมื่อการเชื่อมต่อกลับมา เลเยอร์โดเมนและ UI ของแอปต้องไม่สื่อสารกับเลเยอร์เครือข่ายโดยตรง repository ที่โฮสต์มีหน้าที่รับผิดชอบในการสื่อสารกับอุปกรณ์และใช้เพื่ออัปเดตแหล่งข้อมูลผลิตภัณฑ์ในพื้นที่

การซิงค์ข้อมูลที่กำหนดเองกับการซิงค์ข้อมูลที่มีการจัดการ

การสร้างชั้นข้อมูลที่เน้นการทำงานแบบออฟไลน์เป็นหลักทำได้ 2 วิธีหลักๆ ดังนี้

  • สแต็กการซิงค์ที่กำหนดเอง: คุณดูแลรักษาฐานข้อมูลในเครื่องด้วย Room หรือไฟล์ในพื้นที่เก็บข้อมูลในเครื่อง สร้างไคลเอ็นต์เครือข่ายที่กำหนดเอง และกำหนดเวลาให้ผู้ปฏิบัติงานทำการปรับยอดด้วย WorkManager วิธีนี้ช่วยให้คุณควบคุมสคีมาและอัลกอริทึมการแก้ไขข้อขัดแย้งที่กำหนดเองได้สูงสุด
  • บริการระบบคลาวด์ที่มีการจัดการ: โซลูชันอย่าง Cloud Firestore สามารถให้บริการแคชในเครื่องแบบครบวงจรและสตรีมระบบคลาวด์แบบเรียลไทม์ SDK จะจัดการการแคชดิสก์ การจัดคิวการเขียนแบบออฟไลน์ และการซิงค์แบบ 2 ทิศทางโดยอัตโนมัติ ซึ่งจะส่งสตรีมข้อมูลแบบเรียลไทม์ไปยังที่เก็บข้อมูลของคุณโดยตรง สำหรับแบ็กเอนด์ PostgreSQL เชิงสัมพันธ์ Firebase SQL Connect จะสร้าง SDK ไคลเอ็นต์ Kotlin ที่ปลอดภัยในการกำหนดประเภทซึ่งผสานรวมกับที่เก็บของคุณได้อย่างราบรื่น สำหรับข้อมูลที่ไม่มีโครงสร้าง (เช่น สื่อ ไฟล์ขนาดใหญ่ หรือข้อมูลสำรอง) Cloud Storage for Firebase จะให้บริการพื้นที่เก็บข้อมูลออบเจ็กต์ที่โฮสต์ในระบบคลาวด์พร้อมการดำเนินการอัปโหลดและดาวน์โหลดที่จะหยุดชั่วคราวและกลับมาทำงานอีกครั้งโดยอัตโนมัติเมื่อเครือข่ายหยุดชะงัก

การแสดงทรัพยากร

แหล่งข้อมูลในเครื่องและแหล่งข้อมูลในเครือข่ายอาจแตกต่างกันโดยพื้นฐานในวิธีที่แอปสามารถอ่านและเขียนข้อมูลลงในแหล่งข้อมูลเหล่านั้น การค้นหาแหล่งข้อมูลผลิตภัณฑ์ในพื้นที่อาจรวดเร็วและยืดหยุ่น เช่น เมื่อใช้การค้นหา SQL ในทางตรงกันข้าม แหล่งข้อมูลเครือข่ายอาจช้าและมีข้อจำกัด เช่น เมื่อเข้าถึงทรัพยากร RESTful ทีละส่วนตามรหัส ด้วยเหตุนี้ แหล่งข้อมูลแต่ละแหล่งจึงมักต้องมีการนำเสนอข้อมูลที่ตนเองให้ไว้ในแบบของตนเอง ดังนั้น แหล่งข้อมูลผลิตภัณฑ์ในพื้นที่และแหล่งข้อมูลเครือข่ายจึงอาจมีโมเดลของตนเอง

โครงสร้างไดเรกทอรีต่อไปนี้จะช่วยให้เห็นภาพแนวคิดนี้ AuthorEntity แสดงถึงผู้เขียนที่อ่านจากฐานข้อมูลในเครื่องของแอป และ NetworkAuthor แสดงถึงผู้เขียนที่ได้รับการจัดรูปแบบผ่านเครือข่าย

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

รายละเอียดของ AuthorEntity และ NetworkAuthor มีดังนี้

/**
 * 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,
)

แนวทางปฏิบัติแนะนำคือให้เก็บทั้ง AuthorEntity และ NetworkAuthor ไว้ภายในชั้นข้อมูล และแสดงประเภทที่ 3 สำหรับชั้นภายนอกเพื่อใช้งาน ซึ่งจะช่วยปกป้องเลเยอร์ภายนอกจากการเปลี่ยนแปลงเล็กน้อยในแหล่งข้อมูลในเครื่องและเครือข่ายที่ไม่ได้เปลี่ยนแปลงลักษณะการทำงานของแอปโดยพื้นฐาน ดังที่แสดงในข้อมูลโค้ดต่อไปนี้

/**
 * 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,
)

จากนั้นโมเดลเครือข่ายจะกำหนดเมธอดส่วนขยายเพื่อแปลงเป็นโมเดลในเครื่อง และโมเดลในเครื่องก็มีเมธอดส่วนขยายเพื่อแปลงเป็นการนำเสนอภายนอกในทำนองเดียวกัน ดังที่แสดงในข้อมูลโค้ดต่อไปนี้

/**
 * 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,
)

การอ่าน

การอ่านเป็นการดำเนินการพื้นฐานกับข้อมูลแอปในแอปที่เน้นการทำงานแบบออฟไลน์เป็นหลัก ดังนั้น คุณต้องตรวจสอบว่าแอปสามารถอ่านข้อมูลได้ และแอปจะแสดงข้อมูลใหม่ได้ทันทีที่มีข้อมูลใหม่ แอปที่ทำเช่นนี้ได้คือแอปเชิงรับ เนื่องจากแอปจะแสดง API การอ่านที่มีประเภทที่สังเกตได้

ในข้อมูลโค้ดต่อไปนี้ OfflineFirstTopicRepository จะแสดงผล Flow สำหรับ API การอ่านทั้งหมด ซึ่งจะช่วยให้เครื่องอ่านอัปเดตเมื่อได้รับข้อมูลอัปเดตจากแหล่งข้อมูลเครือข่าย กล่าวคือ ช่วยให้ OfflineFirstTopicRepository พุชการเปลี่ยนแปลงเมื่อแหล่งข้อมูลในเครื่องไม่ถูกต้อง ดังนั้น ผู้อ่าน OfflineFirstTopicRepository แต่ละคนต้องเตรียมพร้อมรับมือกับการเปลี่ยนแปลงข้อมูลที่อาจเกิดขึ้นเมื่อการเชื่อมต่อเครือข่ายของแอปกลับมาใช้งานได้อีกครั้ง นอกจากนี้ OfflineFirstTopicRepository จะอ่านข้อมูลจากแหล่งข้อมูลในเครื่องโดยตรง โดยจะแจ้งให้ผู้อ่านทราบถึงการเปลี่ยนแปลงข้อมูลได้ก็ต่อเมื่ออัปเดตแหล่งข้อมูลผลิตภัณฑ์ในพื้นที่ก่อนเท่านั้น

class TopicsViewModel(
    offlineFirstTopicsRepository: OfflineFirstTopicsRepository
) : ViewModel() {

    val topics: StateFlow<List<Topic>> = offlineFirstTopicsRepository.getTopicsStream()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )
}

ในแอป Jetpack Compose ให้ใช้ ViewModel เพื่อเชื่อมโยงชั้นข้อมูลและ UI ใน ViewModel ให้แปลง Flow เป็น StateFlow โดยใช้โอเปอเรเตอร์ stateIn จากนั้น Composable จะรวบรวมสถานะเหล่านั้นโดยใช้ collectAsStateWithLifecycle() และจัดการการสมัครใช้บริการโดยอัตโนมัติในลักษณะที่คำนึงถึงวงจรชีวิต

ดูข้อมูลเพิ่มเติมเกี่ยวกับ collectAsStateWithLifecycle() ได้ที่สถานะและ Jetpack Compose

กลยุทธ์การจัดการข้อผิดพลาด

มีวิธีจัดการข้อผิดพลาดที่ไม่ซ้ำกันในแอปที่ทำงานแบบออฟไลน์เป็นหลัก โดยขึ้นอยู่กับแหล่งข้อมูลที่อาจเกิดข้อผิดพลาด หัวข้อย่อยต่อไปนี้จะอธิบายกลยุทธ์เหล่านี้

แหล่งข้อมูลผลิตภัณฑ์ในพื้นที่

พยายามลดข้อผิดพลาดเมื่ออ่านจากแหล่งข้อมูลผลิตภัณฑ์ในพื้นที่ เพื่อป้องกันไม่ให้ผู้อ่านเกิดข้อผิดพลาด ให้ใช้โอเปอเรเตอร์ catch ใน Flow ที่ผู้อ่านกำลังรวบรวมข้อมูล

คุณสามารถใช้โอเปอเรเตอร์ catch ใน 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()) }
}

หากต้องการแนวทางที่ยืดหยุ่นมากขึ้น ให้พิจารณาโซลูชัน LCE (ข้อผิดพลาดในการโหลดเนื้อหา) ใน LCE เมื่อเกิดข้อผิดพลาดขณะอ่าน คุณจะแสดงสถานะข้อผิดพลาด โดยปกติแล้ว คุณจะใช้ LCE ได้โดยการจำลองสถานะ UI เป็นคลาสที่ปิดผนึกของ Kotlin

// Define the LCE UI state
sealed interface AuthorUiState {
    data object Loading : AuthorUiState
    data class Success(val author: Author) : AuthorUiState
    data object Error : AuthorUiState
}

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

    // Observe author information and map to LCE state
    val authorUiState: StateFlow<AuthorUiState> =
        authorsRepository.getAuthorStream(id = authorId)
            .map<Author, AuthorUiState> { author ->
                AuthorUiState.Success(author)
            }
            .catch { emit(AuthorUiState.Error) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = AuthorUiState.Loading
            )
}

แหล่งข้อมูลเครือข่าย

หากเกิดข้อผิดพลาดเมื่ออ่านข้อมูลจากแหล่งข้อมูลเครือข่าย แอปจะต้องใช้การเรียนรู้เพื่อลองดึงข้อมูลอีกครั้ง โดยฮิวริสติกที่พบบ่อยมีดังนี้

Exponential Backoff

ใน Exponential Backoff แอปจะพยายามอ่านจากแหล่งข้อมูลเครือข่ายต่อไปโดยเพิ่มช่วงเวลาจนกว่าจะสำเร็จ หรือเงื่อนไขอื่นๆ กำหนดว่าควรหยุด

การอ่านข้อมูลด้วย Exponential Backoff
รูปที่ 2: การอ่านข้อมูลด้วย Exponential Backoff

เกณฑ์ในการประเมินว่าแอปยังคงลดความถี่ในการส่งคำขอหรือไม่ ได้แก่

  • ประเภทข้อผิดพลาดที่แหล่งข้อมูลเครือข่ายระบุ เช่น ลองเรียกใช้เครือข่ายอีกครั้งซึ่งแสดงข้อผิดพลาดที่บ่งชี้ว่าไม่มีการเชื่อมต่อ อย่าลองส่งคำขอ HTTP ที่ไม่ได้รับอนุญาตอีกครั้งจนกว่าจะมีข้อมูลเข้าสู่ระบบที่เหมาะสม
  • จำนวนครั้งสูงสุดที่อนุญาตให้ลองใหม่
การตรวจสอบการเชื่อมต่อเครือข่าย

ในแนวทางนี้ คำขออ่านจะอยู่ในคิวจนกว่าแอปจะแน่ใจว่าสามารถเชื่อมต่อกับแหล่งข้อมูลเครือข่ายได้ เมื่อสร้างการเชื่อมต่อแล้ว ระบบจะยกเลิกคิวคำขออ่าน อ่านข้อมูลผลิตภัณฑ์ในพื้นที่ และอัปเดตแหล่งข้อมูลในเครื่อง ใน Android คิวนี้อาจได้รับการดูแลรักษาด้วยฐานข้อมูล Room และระบายออกเป็นงานถาวรโดยใช้ WorkManager

การอ่านข้อมูลด้วยเครื่องมือตรวจสอบเครือข่ายและคิว
รูปที่ 3: คิวการอ่านที่มีการตรวจสอบเครือข่าย

การเขียน

แม้ว่าวิธีที่แนะนำในการอ่านข้อมูลในแอปที่เน้นการทำงานแบบออฟไลน์คือการใช้ประเภทที่สังเกตได้ แต่ API การเขียนที่เทียบเท่ากันคือ API แบบอะซิงโครนัส เช่น ฟังก์ชันระงับ วิธีนี้จะช่วยหลีกเลี่ยงการบล็อกเธรด UI และช่วยจัดการข้อผิดพลาดเนื่องจากการเขียนในแอปที่ทำงานแบบออฟไลน์เป็นหลักอาจไม่สำเร็จเมื่อข้ามขอบเขตเครือข่าย

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

ในข้อมูลโค้ดก่อนหน้า API แบบอะซิงโครนัสที่เลือกคือ Coroutines เนื่องจากเมธอดหยุดชั่วคราว

กลยุทธ์การเขียน

เมื่อเขียนข้อมูลในแอปที่ทำงานแบบออฟไลน์เป็นหลัก มีกลยุทธ์ 3 ข้อที่ควรพิจารณา ซึ่งคุณจะเลือกวิธีใดนั้นขึ้นอยู่กับประเภทของข้อมูลที่เขียนและข้อกำหนดของแอป ดังนี้

การเขียนแบบออนไลน์เท่านั้น

พยายามเขียนข้อมูลข้ามขอบเขตเครือข่าย หากสำเร็จ ให้อัปเดตแหล่งข้อมูลในเครื่อง ไม่เช่นนั้นให้แสดงข้อยกเว้นและปล่อยให้ผู้โทรตอบกลับอย่างเหมาะสม

การเขียนออนไลน์เท่านั้น
รูปที่ 4: การเขียนแบบออนไลน์เท่านั้น

กลยุทธ์นี้มักใช้กับธุรกรรมการเขียนที่ต้องเกิดขึ้นทางออนไลน์แบบเกือบเรียลไทม์ เช่น การโอนเงินผ่านธนาคาร เนื่องจากการเขียนอาจล้มเหลว จึงมักจำเป็นต้องแจ้งให้ผู้ใช้ทราบว่าการเขียนล้มเหลว หรือป้องกันไม่ให้ผู้ใช้พยายามเขียนข้อมูลตั้งแต่แรก กลยุทธ์ที่คุณใช้ได้ในสถานการณ์เหล่านี้มีดังนี้

  • หากแอปต้องใช้การเข้าถึงอินเทอร์เน็ตเพื่อเขียนข้อมูล คุณสามารถเลือกที่จะไม่แสดง UI แก่ผู้ใช้ที่อนุญาตให้เขียนข้อมูล หรืออย่างน้อยที่สุดก็ปิดใช้ UI ดังกล่าวได้
  • คุณสามารถใช้ AlertDialog ที่ผู้ใช้ปิดไม่ได้ หรือ Snackbar เพื่อแจ้งให้ผู้ใช้ทราบว่าตนออฟไลน์อยู่

การเขียนที่อยู่ในคิว

เมื่อมีออบเจ็กต์ที่ต้องการเขียน ให้แทรกออบเจ็กต์นั้นลงในคิว เมื่อแอปกลับมาออนไลน์ ให้ล้างคิวโดยใช้ Exponential Backoff ใน Android การล้างคิวออฟไลน์เป็นงานที่ทำอย่างต่อเนื่องซึ่งมักจะมอบหมายให้ WorkManager

เขียนคิวที่มีการลองใหม่
รูปที่ 5: คิวการเขียนที่มีการลองใหม่

แนวทางนี้เป็นตัวเลือกที่ดีในสถานการณ์ต่อไปนี้

  • ไม่จำเป็นต้องเขียนข้อมูลลงในเครือข่าย
  • ธุรกรรมนี้ไม่ขึ้นอยู่กับเวลา
  • ไม่จำเป็นต้องแจ้งให้ผู้ใช้ทราบหากการดำเนินการล้มเหลว

กรณีการใช้งานสำหรับแนวทางนี้รวมถึงเหตุการณ์และการบันทึกข้อมูลวิเคราะห์

การเขียนแบบ Lazy

เขียนไปยังแหล่งข้อมูลในเครื่องก่อน จากนั้นจัดคิวการเขียนเพื่อแจ้งเตือนเครือข่ายโดยเร็วที่สุด ซึ่งไม่ใช่เรื่องง่ายเนื่องจากอาจเกิดความขัดแย้งระหว่างเครือข่ายกับแหล่งข้อมูลผลิตภัณฑ์ในพื้นที่เมื่อแอปกลับมาออนไลน์ ส่วนถัดไปเกี่ยวกับการแก้ปัญหาความขัดแย้งจะให้รายละเอียดเพิ่มเติม

การเขียนแบบ Lazy ที่มีการตรวจสอบเครือข่าย
รูปที่ 6: การเขียนแบบ Lazy

แนวทางนี้เป็นตัวเลือกที่ถูกต้องเมื่อข้อมูลมีความสำคัญต่อแอป ตัวอย่างเช่น ในแอปรายการสิ่งที่ต้องทำแบบออฟไลน์เป็นหลัก สิ่งสำคัญคือต้องจัดเก็บงานที่ผู้ใช้เพิ่มแบบออฟไลน์ไว้ในเครื่องเพื่อหลีกเลี่ยงความเสี่ยงที่ข้อมูลจะสูญหาย

การซิงค์ข้อมูลและการแก้ไขข้อขัดแย้ง

เมื่อแอปที่ทำงานแบบออฟไลน์เป็นหลักกู้คืนการเชื่อมต่อได้แล้ว แอปจะต้องปรับยอดข้อมูลในแหล่งข้อมูลในเครื่องให้ตรงกับข้อมูลในแหล่งข้อมูลเครือข่าย กระบวนการนี้เรียกว่าการซิงค์ข้อมูล แอปสามารถซิงค์ข้อมูลกับแหล่งข้อมูลเครือข่ายได้ 2 วิธีหลักๆ ดังนี้

  • การซิงค์ข้อมูลแบบพุล
  • การซิงค์ข้อมูลแบบพุช

การซิงค์ข้อมูลแบบพุล

ในการซิงค์แบบพุล แอปจะเข้าถึงเครือข่ายเพื่ออ่านข้อมูลแอปพลิเคชันล่าสุดตามต้องการ วิธีการทั่วไปสำหรับแนวทางนี้คือการอิงตามการนำทาง ซึ่งแอปจะดึงข้อมูลก่อนที่จะนำเสนอต่อผู้ใช้เท่านั้น

แนวทางนี้เหมาะที่สุดเมื่อแอปคาดว่าจะไม่มีการเชื่อมต่อเครือข่ายเป็นระยะเวลาสั้นๆ ถึงปานกลาง เนื่องจากการรีเฟรชข้อมูลเป็นแบบตามโอกาส และการไม่มีการเชื่อมต่อเป็นเวลานานจะเพิ่มโอกาสที่ผู้ใช้พยายามเข้าชมปลายทางของแอปด้วยแคชที่ล้าสมัยหรือว่างเปล่า

การซิงค์ตามการดึงข้อมูล
รูปที่ 7: การซิงค์แบบพุล: อุปกรณ์ A เข้าถึงทรัพยากรสำหรับหน้าจอ A และ B เท่านั้น ส่วนอุปกรณ์ B เข้าถึงทรัพยากรสำหรับหน้าจอ B, C และ D เท่านั้น

พิจารณาแอปที่ใช้โทเค็นหน้าเพื่อดึงข้อมูลรายการในรายการแบบเลื่อนไม่สิ้นสุดสำหรับหน้าจอหนึ่งๆ การติดตั้งใช้งานอาจเข้าถึงเครือข่ายอย่างล่าช้า เก็บข้อมูลไว้ในแหล่งข้อมูลในเครื่อง แล้วอ่านจากแหล่งข้อมูลในเครื่องเพื่อแสดงข้อมูลกลับไปยังผู้ใช้ ในกรณีที่ไม่มีการเชื่อมต่อเครือข่าย ที่เก็บข้อมูลอาจขอข้อมูลผลิตภัณฑ์ในพื้นที่จากแหล่งข้อมูลในเครื่องเท่านั้น ซึ่งเป็นรูปแบบที่ไลบรารี Jetpack Paging ใช้กับ 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
}

ข้อดีและข้อเสียของการซิงค์แบบพุลสรุปไว้ในตารางต่อไปนี้

ข้อดี ข้อเสีย
ติดตั้งใช้งานได้ค่อนข้างง่าย มีแนวโน้มที่จะใช้ข้อมูลจำนวนมาก เนื่องจากการเข้าชมปลายทางการนำทางซ้ำๆ จะทริกเกอร์การดึงข้อมูลที่ไม่เปลี่ยนแปลงซ้ำโดยไม่จำเป็น คุณลดปัญหานี้ได้ด้วยการแคชที่เหมาะสม ซึ่งทำได้ในเลเยอร์ UI ด้วยโอเปอเรเตอร์ cachedIn หรือในเลเยอร์เครือข่ายด้วยแคช HTTP
ระบบจะไม่ดึงข้อมูลที่ไม่จำเป็น ปรับขนาดได้ไม่ดีนักเมื่อใช้กับข้อมูลเชิงสัมพันธ์ เนื่องจากโมเดลที่ดึงมาต้องมีความสมบูรณ์ในตัวเอง หากโมเดลที่ซิงค์ต้องอาศัยโมเดลอื่นๆ เพื่อดึงข้อมูลมาสร้างตัวเอง ปัญหาการใช้ข้อมูลจำนวนมากที่กล่าวถึงก่อนหน้านี้จะยิ่งมีความสำคัญมากขึ้น นอกจากนี้ ยังอาจทำให้เกิดการขึ้นต่อกันระหว่างที่เก็บของโมเดลหลักและที่เก็บของโมเดลที่ฝังอยู่

การซิงค์ข้อมูลแบบพุช

ในการซิงค์ข้อมูลแบบพุช แหล่งข้อมูลผลิตภัณฑ์ในพื้นที่ในเครื่องจะพยายามเลียนแบบชุดตัวจำลองของแหล่งข้อมูลเครือข่ายอย่างสุดความสามารถ โดยจะดึงข้อมูลในปริมาณที่เหมาะสมในเชิงรุกเมื่อเริ่มต้นใช้งานครั้งแรกเพื่อกำหนดเกณฑ์พื้นฐาน หลังจากนั้น ระบบจะอาศัยการแจ้งเตือนจากเซิร์ฟเวอร์เพื่อแจ้งเตือนเมื่อข้อมูลนั้นล้าสมัย

การซิงค์ข้อมูลแบบพุช
รูปที่ 8: การซิงค์ข้อมูลแบบพุช: เครือข่ายจะแจ้งเตือนแอปเมื่อข้อมูลมีการเปลี่ยนแปลง และแอปจะตอบสนองด้วยการดึงข้อมูลที่เปลี่ยนแปลง

เมื่อได้รับการแจ้งเตือนว่าข้อมูลเก่าแล้ว แอปจะติดต่อเครือข่ายเพื่ออัปเดตเฉพาะข้อมูลที่ทำเครื่องหมายว่าเก่าแล้ว งานนี้จะมอบสิทธิ์ให้แก่ Repository ซึ่งจะเข้าถึงแหล่งข้อมูลเครือข่ายและเก็บข้อมูลที่ดึงมาไว้ในแหล่งข้อมูลในเครื่อง เนื่องจากที่เก็บแสดงข้อมูลด้วยประเภทที่สังเกตได้ ผู้อ่านจึงจะได้รับแจ้งเมื่อมีการเปลี่ยนแปลง

class UserDataRepository(...) {

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

ในแนวทางนี้ แอปจะพึ่งพาแหล่งข้อมูลเครือข่ายน้อยลงมากและสามารถทำงานได้โดยไม่ต้องใช้แหล่งข้อมูลเครือข่ายเป็นเวลานาน โดยจะให้สิทธิ์ทั้งการอ่านและเขียนเมื่อออฟไลน์ เนื่องจากถือว่ามีข้อมูลล่าสุดจากแหล่งข้อมูลเครือข่ายในเครื่อง

ข้อดีและข้อเสียของการซิงค์แบบพุชสรุปไว้ในตารางต่อไปนี้

ข้อดี ข้อเสีย
แอปจะออฟไลน์ได้โดยไม่มีกำหนด ข้อมูลการกำหนดเวอร์ชันสำหรับการแก้ไขข้อขัดแย้งเป็นเรื่องสำคัญ
ใช้อินเทอร์เน็ตน้อยที่สุด แอปจะดึงข้อมูลเฉพาะที่มีการเปลี่ยนแปลงเท่านั้น คุณต้องพิจารณาข้อกังวลเกี่ยวกับการเขียนในระหว่างการซิงค์ข้อมูล
เหมาะสำหรับข้อมูลเชิงสัมพันธ์ ที่เก็บแต่ละแห่งมีหน้าที่ดึงข้อมูลสำหรับโมเดลที่รองรับเท่านั้น แหล่งข้อมูลเครือข่ายต้องรองรับการซิงค์ข้อมูล

การซิงค์ข้อมูลแบบผสม

แอปบางแอปใช้วิธีแบบผสมผสานซึ่งเป็นแบบพุลหรือพุชโดยขึ้นอยู่กับข้อมูล ตัวอย่างเช่น แอปโซเชียลมีเดียอาจใช้การซิงค์แบบพุลเพื่อดึงข้อมูลฟีดที่ผู้ใช้ติดตามตามต้องการเนื่องจากมีการอัปเดตฟีดบ่อยครั้ง แอปเดียวกันอาจเลือกใช้การซิงค์ข้อมูลแบบพุชสำหรับข้อมูลเกี่ยวกับผู้ใช้ที่ลงชื่อเข้าใช้ ซึ่งรวมถึงชื่อผู้ใช้ รูปโปรไฟล์ และอื่นๆ

ท้ายที่สุดแล้ว ตัวเลือกการซิงค์แบบออฟไลน์ก่อนจะขึ้นอยู่กับข้อกำหนดของผลิตภัณฑ์และโครงสร้างพื้นฐานทางเทคนิคที่มีอยู่

การแก้ไขความขัดแย้ง

หากแอปเขียนข้อมูลในเครื่องซึ่งไม่สอดคล้องกับแหล่งข้อมูลเครือข่ายเมื่อออฟไลน์ คุณต้องแก้ไขความขัดแย้งก่อนจึงจะซิงค์ข้อมูลได้

การแก้ไขข้อขัดแย้งมักต้องใช้การกำหนดเวอร์ชัน แอปจำเป็นต้องทำการบันทึกข้อมูลบางอย่างเพื่อติดตามว่าการเปลี่ยนแปลงเกิดขึ้นเมื่อใด เพื่อให้สามารถส่งข้อมูลเมตาไปยังแหล่งข้อมูลเครือข่ายได้ จากนั้นแหล่งข้อมูลเครือข่ายจะมีหน้าที่ให้ข้อมูลที่เป็นความจริงที่แน่นอน มีกลยุทธ์มากมายที่ควรพิจารณาสำหรับการแก้ไขข้อขัดแย้ง โดยขึ้นอยู่กับความต้องการของแอปพลิเคชัน สำหรับแอปบนอุปกรณ์เคลื่อนที่ แนวทางที่ใช้กันโดยทั่วไปคือ "การเขียนครั้งล่าสุดจะชนะ"

การเขียนครั้งล่าสุดจะชนะ

ในแนวทางนี้ อุปกรณ์จะแนบข้อมูลเมตาการประทับเวลาไว้กับข้อมูลที่เขียนลงในเครือข่าย เมื่อแหล่งข้อมูลเครือข่ายได้รับข้อมูลดังกล่าว ระบบจะทิ้งข้อมูลที่เก่ากว่าสถานะปัจจุบันขณะที่ยอมรับข้อมูลที่ใหม่กว่าสถานะปัจจุบัน

การแก้ไขความขัดแย้งแบบการเขียนครั้งล่าสุดชนะ
รูปที่ 9: "การเขียนครั้งสุดท้ายเป็นฝ่ายชนะ" แหล่งข้อมูลที่เชื่อถือได้จะกำหนดโดยเอนทิตีสุดท้ายที่เขียนข้อมูล

ในรูปที่ 9 อุปกรณ์ทั้ง 2 เครื่องออฟไลน์อยู่และซิงค์กับแหล่งข้อมูลเครือข่ายในตอนแรก ขณะออฟไลน์ ทั้ง 2 คนจะเขียนข้อมูลไว้ในเครื่องและติดตามเวลาที่เขียนข้อมูล เมื่อทั้ง 2 อุปกรณ์กลับมาออนไลน์และซิงค์กับแหล่งข้อมูลเครือข่าย เครือข่ายจะแก้ไขความขัดแย้งโดยคงข้อมูลจากอุปกรณ์ B ไว้เนื่องจากอุปกรณ์ B เขียนข้อมูลทีหลัง

WorkManager ในแอปที่ทำงานแบบออฟไลน์เป็นหลัก

ทั้งกลยุทธ์การอ่านและการเขียนที่กล่าวถึงก่อนหน้านี้มียูทิลิตีทั่วไป 2 รายการ ได้แก่

  • คิว
    • การอ่าน: ใช้เพื่อเลื่อนการอ่านออกไปจนกว่าจะมีการเชื่อมต่อเครือข่าย
    • การเขียน: ใช้เพื่อเลื่อนการเขียนจนกว่าจะมีการเชื่อมต่อเครือข่าย และเพื่อจัดคิวการเขียนใหม่สำหรับการลองอีกครั้ง
  • เครื่องมือตรวจสอบการเชื่อมต่อเครือข่าย
    • การอ่าน: ใช้เป็นสัญญาณเพื่อล้างคิวการอ่านเมื่อแอปเชื่อมต่ออยู่ และใช้สำหรับการซิงค์ข้อมูล
    • การเขียน: ใช้เป็นสัญญาณเพื่อระบายคิวการเขียนเมื่อแอปเชื่อมต่ออยู่ และใช้สำหรับการซิงค์ข้อมูล

ทั้ง 2 กรณีเป็นตัวอย่างของงานที่ต่อเนื่องซึ่ง WorkManager ทำได้ดี

ตัวอย่างเช่น ในแอปตัวอย่าง Now in Android ระบบจะใช้ WorkManager เป็นทั้งคิวการอ่านและตัวตรวจสอบเครือข่ายเมื่อซิงค์ข้อมูลแหล่งข้อมูลในเครื่อง เมื่อเริ่มต้นระบบ แอปจะดำเนินการดังนี้

  1. จัดคิวงานการซิงค์การอ่านเพื่อให้แน่ใจว่าแหล่งข้อมูลผลิตภัณฑ์ในพื้นที่และแหล่งข้อมูลเครือข่ายมีความเท่าเทียมกัน
  2. ล้างคิวการซิงค์การอ่านและเริ่มซิงค์เมื่อแอปออนไลน์
  3. ดำเนินการอ่านจากแหล่งข้อมูลเครือข่ายโดยใช้ Exponential Backoff
  4. เก็บผลลัพธ์ของการอ่านไว้ในแหล่งข้อมูลผลิตภัณฑ์ในพื้นที่และแก้ไขข้อขัดแย้งที่เกิดขึ้น
  5. แสดงข้อมูลผลิตภัณฑ์ในพื้นที่จากแหล่งข้อมูลในเครื่องเพื่อให้เลเยอร์อื่นๆ ของแอปใช้

การดำเนินการเหล่านี้แสดงอยู่ในแผนภาพต่อไปนี้

การซิงค์ข้อมูลในแอป Now in Android
รูปที่ 10: การซิงค์ข้อมูลในแอป Now in Android

การจัดคิวงานการซิงค์ข้อมูลด้วย WorkManager จะตามด้วยการระบุว่าเป็นงานที่ไม่ซ้ำกันด้วย KEEP 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
   }
}

SyncWorker.startupSyncWork() มีคำจำกัดความดังนี้


/**
 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()

กล่าวคือ Constraints ที่กำหนดโดย SyncConstraints กำหนดให้ NetworkType ต้องเป็น NetworkType.CONNECTED กล่าวคือ ระบบจะรอจนกว่าเครือข่ายจะพร้อมใช้งานก่อนจึงจะทำงาน

เมื่อเครือข่ายพร้อมใช้งาน Worker จะล้างคิวงานที่ไม่ซ้ำกันซึ่งระบุโดย SyncWorkName โดยมอบสิทธิ์ให้อินสแตนซ์ Repository ที่เหมาะสม หากการซิงค์ไม่สำเร็จ เมธอด doWork() จะแสดงผลเป็น Result.retry() WorkManager จะลองซิงค์ข้อมูลอีกครั้งโดยอัตโนมัติด้วย Exponential Backoff ไม่เช่นนั้น ระบบจะแสดงผลเป็น 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()
    }
}

ตัวอย่าง

ตัวอย่างต่อไปนี้ของ Google แสดงแอปที่ทำงานแบบออฟไลน์เป็นหลัก ลองสำรวจแหล่งข้อมูลต่อไปนี้เพื่อดูตัวอย่างการนำคำแนะนำนี้ไปใช้จริง