Android Architecture Components — Part2 : Data Layer
Table of Contents
- Part 1 — Introduction
- Part 2 — Data Layer <- You are here
- Part 3 — Presentation Layer
- Part 4 — Demo
- Part 5 — Addendum
ก่อนที่เราจะเริ่มกัน ให้ไปโหลดโค้ดของโปรเจคนี้แล้วเอาไปเปิดใน Android Studio ประกอบการอ่านได้เลยครับตามลิงค์นี้
Data Layer
source code package: com.blacklenspub.postsreader.data
ใน data layer เราจะมี repository แค่อันเดียวก็คือ PostRepository
ทำหน้าที่ดึงข้อมูล Post ซึ่งมันจะใช้งาน remote source และ local source
Remote Source
ในส่วนของ remote source นั้นผมเลือกใช้ Retrofit ด้วยเหตุผลที่ว่าหลายคนน่าจะคุ้นเคยอยู่แล้ว โดยเราจะประกาศ PostsReaderApi
ซึ่งเป็น Interface สำหรับ API ของเราไว้ตามนี้
interface PostsReaderApi { @GET("posts")
fun getAllPosts(): Single<List<Post>> @GET("posts/{id}")
fun getPostById(@Path("id") id: String): Single<Post>
}
ส่วนของ remote source ง่ายๆ ตรงไปตรงมาแค่นี้ ถ้าใครเคยใช้ Retrofit อยู่แล้วคงคุ้นเคยอยู่ดีกับการประกาศ Interface สำหรับ API endpoint แบบนี้ แต่จะสังเกตนิดนึงว่าผมให้แต่ละฟังก์ชันรีเทิร์นค่าออกมาเป็น Single ซึ่งเป็นส่วนของ RxJava เนื่องด้วยมันทำให้เราเขียน Asynchronous callback และสั่ง network operation บน Background Thread ได้อย่างง่ายดาย จริงๆ เราสามารถรีเทิร์นเปน LiveData
เลยก็ได้ครับ แต่แค่ว่า ณ ตอนที่เขียนนี้ Retrofit ยังไม่มี CallAdapter
มาให้เหมือน RxJava ถ้าอยากใช้จริงๆก้อต้องเขียนเอง ลองดูตัวอย่างจาก Google ดูก็ได้ครับ
ส่วนโค้ดในการสร้าง Retrofit และ API นั้นอยู่ใน Module ของ Dagger ที่ชื่อ RemoteDataModule
ซึ่งโค้ดตัวอย่างจะอยู่ใน part สุดท้ายครับ หรือจะเข้าไปดูโค้ดจริงใน Android Studio ก่อนก็ได้
Local Source
ในส่วน local source เราจะใช้ Room ซึ่งเป็นส่วนหนึ่งใน AAC ถ้าใครเคยใช้ SQLite ของแอนดรอยอยู่แล้วก็คงคุ้นเคยกับการที่ต้องมาทำ database helper การระบุ projection ตอน query ทำอะไรเยอะแยะไปหมด การมาของ Room จะมาช่วยทำให้ทุกอย่างง่ายขึ้น
จากภาพด้านบนเราจะเห็น Component ต่างๆ ของ Room และความสัมพันธ์ของมันได้อย่างชัดเจนดังนี้
- Entity คือส่วนของข้อมูลจริงๆ ในเดโมแอปของเราก็คือคลาส
Post
นั่นเอง - ส่วน Data Access Objects หรือ DAO นั้นคือส่วนหลักที่เราจะใช้ทำ CRUD functions ทั้งหมดเช่น query all posts, create new post
- ส่วน Room Database ก็คือส่วนที่ไว้คอนฟิก database และเข้าถึง DAO
เรามาลองดูโค้ดกันดีกว่า เริ่มที่ส่วน Entity ซึ่งง่ายสุดและในแอปนี้มีแค่คลาสเดียวนั่นก็คือ Post
@Entity(tableName = "post")
class Post { @PrimaryKey
@ColumnInfo(name = "id")
lateinit var id: String @ColumnInfo(name = "title")
lateinit var title: String @ColumnInfo(name = "body")
lateinit var body: String
}
- เราใส่ annotation
@Entity
พร้อมทั้งระบุtableName
ซึ่งจะเป็นชื่อตารางในฐานข้อมูลว่า “post” - ใส่ annotation
@ColumnInfo
ให้ property ซึ่งจะกลายเป็นแต่ละคอลัมน์ในตาราง - ใส่ annotation
@PrimaryKey
ให้กับ property ที่จะใช้เป็น Primary Key ของตาราง
เท่าที่ผมลองเล่นมา Entity ของ Room ยังมีปัญหาอยู่ถ้าเราใช้กับ data class ของ Kotlin นะครับเพราะ Room หา default constructor กับ getter functions ของ data class ไม่เจอ ผมจึงใช้ Class กับ Property ปกติแทน
ส่วนต่อมาก็คือ DAO ครับ
@Dao
interface PostDao { @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrUpdatePosts(vararg posts: Post) @Query("SELECT * FROM post")
fun getAllPosts(): LiveData<List<Post>> // id is changed to arg0 in generated code
@Query("SELECT * FROM post WHERE id = :arg0")
fun getPostById(id: String): LiveData<Post>
}
DAO นี้คือส่วนหลักเลยครับ เพราะเป็นจุดที่เราจะประกาศฟังก์ชันที่เราไว้ใช้งาน ซึ่ง PostDao
นี้
- ประกาศเป็น interface และมี annotation
@Dao
- แต่ละฟังก์ชันจะมี annotation ระบุ CRUD function ที่ใช้เช่น
@Query
หรือ@Insert
- ความเจ๋งอีกอย่างคือ SQL staement ที่เราใส่ให้กับ
@Query
จะถูกตรวจสอบตอน compile time หมายความว่าถ้าเราใส่ SQL statement ผิด แอปก็จะคอมไพล์ไม่ผ่านเลย เยี่ยมไหมหละ
ตอนที่เรา build แอป Room จะ generate implementation ของ DAO มาให้เราเอง
อีกหนึ่งปัญหาเล็กๆ (ในตอนนี้) เมื่อใช้ Room กับ Kotlin คือเรื่อง query parameter อย่างเช่นในฟังก์ชัน getPostById() ผมต้องระบุใน SQL Statement ว่า id = :arg0 เนื่องจากชื่อตัวแปร id ดันถูกเปลี่ยนเป็น arg0 ตอนโค้ดถูก generate ออกมา
ฟังก์ชัน getAllPosts()
และ getPostById()
ซึ่งทำหน้าที่ query ผมให้มันรีเทิร์นค่าเป็น LiveData
ซึ่งเราจะได้ข้อมูล Post
ที่ Room query มาให้ผ่านทาง LiveData
และถ้าหลังจากนี้มีการเปลี่ยนแปลงของข้อมูล Post
ใน database เราก็จะได้อัพเดทผ่าน LiveData
นี้ด้วย เรียกได้ว่า push มาให้โดยที่ไม่ต้องเข้าไป query ใหม่นั่นเอง
LiveData
LiveData
เป็น component ใน AAC ที่ทำให้เราสามารถ observe dataในลักษณะ Observer Pattern ได้ (คล้ายกันกับ Observable ของ RxJava) นอกจากนี้LiveData
จะส่งข้อมูลให้ Observer เฉพาะตอนที่ Observer อยู่ในสถานะ Active หรือพร้อมรับข้อมูลเท่านั้น ตัวอย่างเช่นถ้า Observer คือActivity
ถึงจะมีข้อมูลอัพเดทมาใหม่แต่LiveData
จะไม่ส่งข้อมูลให้ถ้าActivity
อยู่ในสถานะ ON_STOP
ส่วนต่อไปก็คือส่วนการประกาศ Database
@Database(entities = arrayOf(Post::class), version = 1)
abstract class AppDatabase : RoomDatabase() { abstract fun postDao(): PostDao
}
ตรงนี้คือส่วนที่ทุกอย่างจะมาเชื่อมกันแล้ว
- ประกาศ abstract class
AppDatabase
ที่ extend มาจากRoomDatabase
- ใส่ annotation
@Database
และระบุ Entity ที่ใช้ผ่านตัวแปรentities
- เพิ่ม abstract function ที่รีเทิร์นค่าเป็น DAO ที่เราทำไว้ ในที่นี้คือ
postDao()
และแน่นอนโค้ดในส่วนของการสร้าง Room Database และ DAO ผมใส่ไว้ใน Dagger Module ตามระเบียบ ให้ชื่อว่า LocalDataModule
ซึ่งโค้ดส่วนนี้จะอยู่ใน part สุดท้ายครับ หรือจะเข้าไปดูในโปรเจคจริงก่อนก็ได้
Repository
Repository จะทำหน้าที่จัดการการใช้งานข้อมูลทั้งจาก local source และ remote source ที่เราเตรียมไว้ คลาสใน presentation layer จะใช้งานข้อมูลผ่าน repository ไม่ได้คุยกับ remote source หรือ local source โดยตรง ข้อดีของวิธีนี้คือเราสามารถปรับเปลี่ยน local source หรือ remote source ได้โดยไม่กระทบกับโค้ดด้านบน เช่นถ้าเราเบื่อ Room อยากเปลี่ยนไปใช้ Realm หรืออยากเปลี่ยนจาก Retrofit ไปใช้ Fuel ก็สามารถทำได้โดยไม่กระทบฝั่ง presentation layer
class PostRepository(val localSource: PostDao, val remoteSource: PostsReaderApi) { fun getAllPosts(): LiveData<List<Post>> {
remoteSource.getAllPosts().subscribe { posts, _ ->
localSource.insertOrUpdatePosts(*posts.toTypedArray())
}
return localSource.getAllPosts()
}
// ... other functions are omitted.
}
จากโค้ดข้างบน
- ฟังก์ชัน
getAllPosts()
รีเทิร์นค่าออกมาเป็นLiveData<List<Post>>
นั่นก็หมายความว่าเราสามารถ observePost
ทั้งหมดที่ถูกส่งออกมาจากLiveData
ก้อนนี้ได้ - ฟังก์ชันนี้รีเทิร์น
LiveData
ที่ออกมาจากlocalSource
เพียงด้านเดียว ส่วนข้อมูลที่ได้มาจากremoteSource
จะถูกเอาไปใส่localSource
ซึ่งจะทำให้LiveData
อัพเดทอัตโนมัติเช่นกัน - การที่เรารีเทิร์น
LiveData
ที่มาจากสายlocalSource
เพียงด้านเดียว เป็นการทำตามหลัก Single Source of Truth โดยlocalSource
(Room Database) คือ Single Source of Truth ในกรณีนี้
และตามระเบียบครับ โค้ดในการสร้าง Repository อยู่ใน Dagger Module ที่ชื่อ PostRepositoryModule
ซึ่งโค้ดส่วนนี้จะอยู่ใน part สุดท้ายครับ หรือจะเข้าไปดูใน โปรเจคจริงก็ได้
Unit Test
เมื่อเราเทส PostRepository
เราสามารถที่จะใช้ Mockito ในการ mock ทั้ง localSource
และ remoteSource
ได้เลย ผมดึงเฉพาะส่วนที่เทสฟังก์ชัน getAllPosts()
ของ PostRepository
มาแสดงในนี้นะครับ ฉบับเต็มดูได้ในโปรเจคจริงตรงโฟลเดอร์ /test
ได้เลย
@Test
fun getAllPosts() {
//... mocking of remoteSource, localSource and observer are omitted. sutRepo.getAllPosts().observeForever(observer) verify(remoteSource).getAllPosts()
verify(localSource).insertOrUpdatePosts(mockedPosts)
verify(localSource).getAllPosts()
verify(observer).onChanged(mockedPosts)
}
ติดตามต่อไปยัง Part 3 ส่วนของ Presentation Layer ได้เลยครับ