ชั้นข้อมูล

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

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

สถาปัตยกรรมชั้นข้อมูล

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

ในสถาปัตยกรรมทั่วไป ที่เก็บของ Data Layer จะให้ข้อมูล
    แก่ส่วนอื่นๆ ของแอปและขึ้นอยู่กับแหล่งข้อมูล
รูปที่ 1 บทบาทของ Data Layer ในสถาปัตยกรรมของแอป

คลาสที่เก็บมีหน้าที่รับผิดชอบต่องานต่อไปนี้

  • การเปิดเผยข้อมูลต่อส่วนอื่นๆ ของแอป
  • รวมการเปลี่ยนแปลงข้อมูลไว้ที่เดียว
  • การแก้ไขความขัดแย้งระหว่างแหล่งข้อมูลหลายแหล่ง
  • แยกแหล่งที่มาของข้อมูลออกจากส่วนอื่นๆ ของแอป
  • มีตรรกะทางธุรกิจ

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

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

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

ที่เก็บจะใช้แหล่งข้อมูลเป็นทรัพยากร Dependency ในตัวสร้างตามแนวทางปฏิบัติแนะนำของการแทรกทรัพยากร Dependency

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

เปิดเผย API

โดยทั่วไปแล้ว คลาสในชั้นข้อมูลจะแสดงฟังก์ชันเพื่อทำการเรียกใช้ Create, Read, Update และ Delete (CRUD) แบบครั้งเดียว หรือเพื่อรับการแจ้งเตือนเกี่ยวกับการเปลี่ยนแปลงข้อมูลเมื่อเวลาผ่านไป ชั้นข้อมูลควรแสดงข้อมูลต่อไปนี้สำหรับแต่ละกรณี

  • สำหรับการดำเนินการแบบครั้งเดียว ให้แสดงฟังก์ชันระงับ
  • หากต้องการรับการแจ้งเตือนเกี่ยวกับการเปลี่ยนแปลงข้อมูลเมื่อเวลาผ่านไป ให้แสดงโฟลว์
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

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

แบบแผนการตั้งชื่อในคู่มือนี้

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

ประเภทข้อมูล + ที่เก็บ

เช่น NewsRepository, MoviesRepository หรือ PaymentsRepository

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

ประเภทข้อมูล + ประเภทแหล่งที่มา + DataSource

สําหรับประเภทข้อมูล ให้ใช้ Remote หรือ Local เพื่อให้ครอบคลุมมากขึ้นเนื่องจาก การติดตั้งใช้งานอาจเปลี่ยนแปลงได้ เช่น NewsRemoteDataSource หรือ NewsLocalDataSource หากต้องการระบุให้เจาะจงมากขึ้นในกรณีที่แหล่งที่มามีความสำคัญ ให้ใช้ ประเภทของแหล่งที่มา เช่น NewsNetworkDataSource หรือ NewsDiskDataSource

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

ที่เก็บหลายระดับ

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

เช่น ที่เก็บที่จัดการข้อมูลการตรวจสอบสิทธิ์ผู้ใช้ UserRepository อาจขึ้นอยู่กับที่เก็บอื่นๆ เช่น LoginRepository และ RegistrationRepository เพื่อให้เป็นไปตามข้อกำหนด

ในตัวอย่างนี้ UserRepository ขึ้นอยู่กับคลาส Repository อื่นๆ 2 คลาส ได้แก่
    LoginRepository ซึ่งขึ้นอยู่กับแหล่งข้อมูลการเข้าสู่ระบบอื่นๆ และ
    RegistrationRepository ซึ่งขึ้นอยู่กับแหล่งข้อมูลการลงทะเบียนอื่นๆ
รูปที่ 2 กราฟทรัพยากร Dependency ของที่เก็บที่ขึ้นอยู่กับที่เก็บอื่น

แหล่งข้อมูลที่เชื่อถือได้

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

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

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

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

การจัดชุดข้อความ

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

โปรดทราบว่าแหล่งข้อมูลส่วนใหญ่มี API ที่ปลอดภัยในเทรดหลักอยู่แล้ว เช่น การเรียกใช้เมธอด suspend ที่ Room, Retrofit หรือ Ktor มีให้ โดย ที่เก็บของคุณจะใช้ประโยชน์จาก API เหล่านี้ได้เมื่อพร้อมใช้งาน

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

อายุการใช้งาน

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

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

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

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

เป็นตัวแทนโมเดลธุรกิจ

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

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

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
)

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

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

การแยกคลาสโมเดลมีประโยชน์ดังนี้

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

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

ประเภทของการดำเนินการกับข้อมูล

ชั้นข้อมูลสามารถจัดการกับการดำเนินการประเภทต่างๆ ที่แตกต่างกันไปตามความสำคัญของการดำเนินการนั้นๆ ได้แก่ การดำเนินการที่มุ่งเน้น UI, การดำเนินการที่มุ่งเน้นแอป และการดำเนินการที่มุ่งเน้นธุรกิจ

การดำเนินการที่มุ่งเน้น UI

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

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

การดำเนินการที่มุ่งเน้นแอป

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

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

การดำเนินการที่มุ่งเน้นธุรกิจ

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

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

แสดงข้อผิดพลาด

การโต้ตอบกับที่เก็บและแหล่งข้อมูลอาจสำเร็จหรือทำให้เกิด ข้อยกเว้นเมื่อเกิดข้อผิดพลาด สำหรับ Coroutines และ Flow คุณควรใช้กลไกการจัดการข้อผิดพลาดในตัวของ Kotlin สำหรับข้อผิดพลาดที่อาจเกิดจากฟังก์ชันระงับ ให้ใช้บล็อก try/catch เมื่อเหมาะสม และในโฟลว์ ให้ใช้ตัวดำเนินการ catch ด้วยแนวทางนี้ เลเยอร์ UI ควรจัดการข้อยกเว้นเมื่อเรียกใช้ชั้นข้อมูล

Data Layer สามารถทําความเข้าใจและจัดการข้อผิดพลาดประเภทต่างๆ รวมถึงแสดงข้อผิดพลาดเหล่านั้นโดยใช้ข้อยกเว้นที่กําหนดเองได้ เช่น UserNotAuthenticatedException

ดูข้อมูลเพิ่มเติมเกี่ยวกับข้อผิดพลาดในโครูทีนได้ที่บล็อกโพสต์ข้อยกเว้นในโครูทีน

งานทั่วไป

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

ส่งคำขอเครือข่าย

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

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

สร้างแหล่งข้อมูล

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

การส่งคำขอเครือข่ายเป็นการเรียกใช้แบบครั้งเดียวซึ่งจัดการโดยfetchLatestNews() เมธอดใหม่

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>
}

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

สร้างที่เก็บ

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

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

ดูวิธีใช้คลาสที่เก็บโดยตรงจากเลเยอร์ UI ได้ที่คู่มือเลเยอร์ UI

ใช้การแคชข้อมูลในหน่วยความจำ

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

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

แคช

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

แคชผลลัพธ์ของคำขอเครือข่าย

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

การติดตั้งใช้งานต่อไปนี้จะแคชข้อมูลข่าวสารล่าสุดไปยังตัวแปรใน ที่เก็บที่ได้รับการป้องกันการเขียนด้วย Mutex หากคำขอเครือข่ายสำเร็จ ระบบจะกำหนดข้อมูลให้กับตัวแปร 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 }
    }
}

ทำให้การดำเนินการมีอายุยาวนานกว่าหน้าจอ

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

NewsRepository ควรรับขอบเขตเป็นพารามิเตอร์ในเครื่องมือสร้างแทนที่จะสร้างขอบเขตของตัวเอง CoroutineScope เพื่อให้เป็นไปตามแนวทางปฏิบัติแนะนำในการแทรกทรัพยากร Dependency เนื่องจากที่เก็บควรทำงานส่วนใหญ่ใน เธรดเบื้องหลัง คุณจึงควรกำหนดค่า CoroutineScope ด้วย Dispatchers.Default หรือด้วยกลุ่มเธรดของคุณเอง

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

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

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

อ่านเพิ่มเติมเกี่ยวกับรูปแบบสำหรับ CoroutineScope

บันทึกและเรียกข้อมูลจากดิสก์

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

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

  • สำหรับชุดข้อมูลขนาดใหญ่ที่ต้องมีการค้นหา ต้องมี Referential Integrity หรือต้องมีการอัปเดตบางส่วน ให้บันทึกข้อมูลในฐานข้อมูล Room ในแอปข่าว ตัวอย่าง คุณอาจบันทึกบทความข่าวหรือผู้เขียนไว้ในฐานข้อมูล
  • สำหรับชุดข้อมูลขนาดเล็กที่ต้องดึงและตั้งค่าเท่านั้น (ไม่ใช่การค้นหาหรืออัปเดตบางส่วน) ให้ใช้ DataStore ในตัวอย่างแอปข่าว ระบบอาจบันทึกรูปแบบวันที่ที่ผู้ใช้ต้องการหรือค่ากำหนดการแสดงผลอื่นๆ ไว้ใน DataStore
  • สำหรับกลุ่มข้อมูล เช่น ออบเจ็กต์ JSON ให้ใช้ไฟล์

ดังที่กล่าวไว้ในส่วนแหล่งข้อมูลที่เชื่อถือได้ แหล่งข้อมูลแต่ละแหล่งจะทำงานร่วมกับแหล่งข้อมูลเพียงแหล่งเดียวและสอดคล้องกับประเภทข้อมูลที่เฉพาะเจาะจง (เช่น News, Authors, NewsAndAuthors หรือ UserPreferences) คลาสที่ใช้แหล่งข้อมูลไม่ควรรู้ว่าระบบบันทึกข้อมูลอย่างไร เช่น ในฐานข้อมูลหรือในไฟล์

Room เป็นแหล่งข้อมูล

เนื่องจากแหล่งข้อมูลแต่ละแหล่งควรมีหน้าที่รับผิดชอบในการทำงานร่วมกับแหล่งข้อมูลเพียงแหล่งเดียวสำหรับข้อมูลประเภทหนึ่งๆ แหล่งข้อมูลของ Room จึงจะรับ ออบเจ็กต์การเข้าถึงข้อมูล (DAO) หรือฐานข้อมูลเองเป็นพารามิเตอร์ เช่น NewsLocalDataSource อาจใช้อินสแตนซ์ของ NewsDao เป็นพารามิเตอร์ และ AuthorsLocalDataSource อาจใช้อินสแตนซ์ของ AuthorsDao

ในบางกรณี หากไม่จำเป็นต้องมีตรรกะเพิ่มเติม คุณสามารถแทรก DAO ลงในที่เก็บได้โดยตรง เนื่องจาก DAO เป็นอินเทอร์เฟซที่คุณแทนที่ได้ง่ายๆ ในการทดสอบ

ดูข้อมูลเพิ่มเติมเกี่ยวกับการทำงานกับ Room API ได้ที่คำแนะนำเกี่ยวกับ Room

DataStore เป็นแหล่งข้อมูล

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

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

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

ดูข้อมูลเพิ่มเติมเกี่ยวกับการทำงานกับ DataStore API ได้ที่คำแนะนำเกี่ยวกับ DataStore

ไฟล์เป็นแหล่งข้อมูล

เมื่อทำงานกับออบเจ็กต์ขนาดใหญ่ เช่น ออบเจ็กต์ JSON หรือบิตแมป คุณจะต้องทำงานกับออบเจ็กต์ File และจัดการการสลับเทรด

ดูข้อมูลเพิ่มเติมเกี่ยวกับการทำงานกับที่เก็บไฟล์ได้ที่หน้าภาพรวมของพื้นที่เก็บข้อมูล

กำหนดเวลางานโดยใช้ WorkManager

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

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

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()
    }
}

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

ในตัวอย่างนี้ ต้องเรียกใช้ทาสก์ที่เกี่ยวข้องกับข่าวนี้จาก NewsRepository ซึ่งจะใช้แหล่งข้อมูลใหม่เป็นทรัพยากร Dependency NewsTasksDataSource โดยใช้การติดตั้งใช้งานดังนี้

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)
    }
}

คลาสประเภทเหล่านี้ตั้งชื่อตามข้อมูลที่รับผิดชอบ เช่น NewsTasksDataSource หรือ PaymentsTasksDataSource งานทั้งหมดที่เกี่ยวข้อง กับข้อมูลประเภทใดประเภทหนึ่งควรห่อหุ้มไว้ในคลาสเดียวกัน

หากต้องทริกเกอร์งานเมื่อแอปเริ่มต้นทำงาน ขอแนะนำให้ทริกเกอร์คำขอ WorkManager โดยใช้ไลบรารีApp Startup ที่เรียกที่เก็บจาก Initializer

ดูข้อมูลเพิ่มเติมเกี่ยวกับการทำงานกับ WorkManager API ได้ที่คำแนะนำ WorkManager

การทดสอบ

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

การทดสอบหน่วย

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

การทดสอบการผสานรวม

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

สำหรับฐานข้อมูล Room อนุญาตให้สร้างฐานข้อมูลในหน่วยความจำที่คุณควบคุมได้อย่างเต็มที่ในการทดสอบ ดูข้อมูลเพิ่มเติมได้ที่หน้าทดสอบและแก้ไขข้อบกพร่องของฐานข้อมูล

สำหรับการเชื่อมต่อเครือข่าย มีไลบรารียอดนิยม เช่น WireMock หรือ MockWebServer ที่ช่วยให้คุณจำลองการเรียก HTTP และ HTTPS และยืนยันว่า คำขอเป็นไปตามที่คาดไว้

แหล่งข้อมูลเพิ่มเติม

ตัวอย่าง