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

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

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

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

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

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

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

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

ออกแบบแอปที่ทำงานแบบออฟไลน์ก่อน

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

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

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

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

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

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

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

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

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

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

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

การเปิดเผยทรัพยากร

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • การซิงค์แบบดึง
  • การซิงค์แบบพุช

การซิงค์แบบดึง

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

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

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

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

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

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

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

การซิงค์แบบไฮบริด

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

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

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

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

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

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

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

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

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

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 แสดงแอปที่ทำงานแบบออฟไลน์ก่อน ลองสำรวจเพื่อดูคำแนะนำนี้ในทางปฏิบัติ