Android Architecture Components — Part2 : Data Layer

Dew
Black Lens
Published in
4 min readJun 20, 2017

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

ใน 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 ภาพประกอบจาก https://developer.android.com/topic/libraries/architecture/room.html

จากภาพด้านบนเราจะเห็น 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>> นั่นก็หมายความว่าเราสามารถ observe Post ทั้งหมดที่ถูกส่งออกมาจาก 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 ได้เลยครับ

--

--