[Android] มาลองใช้ Paging library Architecture Components

Pattadon Adhipanyasarij
5 min readJul 24, 2018

--

Blog นี้จะมาสอนวิธีการใช้งาน Paging Library แบบคร่าวๆกัน

Paging Library คืออะไร ?

Developer ส่วนใหญ่มักจะต้องเจอกับ application ที่ต้องแสดงข้อมูลจำนวนมากๆด้วย Recycler View และต้องเพิ่ม function บางอย่างเช่น Load more เข้าไป แต่ถ้าเราใช้ Architecture Components ของ Android เราจะต้องจัดการเขียน Code ลำบากขึ้นไปอีกขั้นนึง นั่นทำให้ทีมพัฒนา Android Framework ออกเจ้า Paging มา

ก่อนที่จะเริ่มใช้งานเจ้า Paging ผมขอแนะนำให้ทุกคนรู้เรื่องเกี่ยวกับ Architecture ทั้ง 3 ตัวนี้ก่อน Room, LiveData ,ViewModel เพราะมันเกี่ยวข้องกับ Paging ที่เราจะต้องใช้นั่นเอง

ทำไมถึงต้องใช้ Paging ?

อ้างอิงจาก Slide งาน Google IO 2018

โดยทั่วไปการแสดงข้อมูลบน RecyclerView เราจะออกแบบ Structure ได้ประมาณนี้

  1. เรียกข้อมูลจาก Server มาเขียนลง Room (Database)
  2. นำข้อมูลจาก Database มา query ส่งผ่านมาที่ ViewModel
  3. LiveData จาก ViewModel จะ Update เข้าสู่ UI โดยอัติโนมัติ ทำ Update ใน RecyclerView
เบื้องหลังการทำงานทั่วไป (ไม่ใช่ Paging นะ)

** โดยถ้าใครไม่ได้อยากใช้ Room เปลี่ยนเป็นยิงข้อมูลตรงเข้าสู่ ViewModel เลยก็ได้ (ตัดข้อ 2 ออก)

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

นั่นหมายความว่าเราต้องอัพเดพข้อมูลทั้งหมด 10000 item !!

นั่นละครับ ถึงเป็นที่มาว่า ทำไมทีมพัฒนา Android Framework จึงออกเจ้า

Paging Library มีหลักๆมีอะไรบ้าง ?

หลักๆก็จะมีประมาณ 5 ตัวนี้

ก่อนเริ่มใช้งาน ก็ต้องประกาศเจ้าสิ่งนี้ใน gradle ก่อนเลย

implementation 'android.arch.paging:runtime:1.0.0'

ตัว Architecture Component Paging สามารถใช้งานแบบไหนได้บ้าง

  • Network only จะใช้ Retrofit เป็นตัวโหลดจัดการข้อมูล
  • Database only จะใช้ Room ของ Database เป็นตัวจัดการข้อมูล
  • Network and database จะใช้ PagedList.BoundaryCallback เดี๋ยวจะอธิบายเพิ่มเติมทีหลังครับ

ก่อนจะเริ่มใช้งาน เราควรเข้าใจสิ่งพวกนี้ก่อนนะครับ

  • DiffUtil ของ RecyclerView

เจ้าสิ่งนี้เริ่มเพิ่มเข้ามาใน RecyclerView เวอร์ชั่น 24.2.0 ถ้าพูดสั้นๆ คือมันช่วยจัดการแทนที่เราจะใช้ notifyDataSetChange() ที่จะทำการอัพเดพ ทุก Item เจ้านี้จะช่วยปรับการ Update เฉพาะ item ที่มีการเปลี่ยนแปลง เพื่อทำให้ Performance
ของแอปดีขึ้น

ตัวอย่างการใช้งาน

val DiffCallback = object : DiffUtil.ItemCallback<FeedResult>() {
override fun areItemsTheSame(oldItem: FeedResult?, newItem: FeedResult?): Boolean {
return oldItem?.contentId == newItem?.contentId
}

override fun areContentsTheSame(oldItem: FeedResult?, newItem: FeedResult?): Boolean {
return oldItem == newItem
}
}

เจ้าของ blog แนะนำลองไปอ่าน DiffUtil ของคนอื่นดู น่าจะเข้าใจดีกว่า

เริ่มเข้าใจทีละส่วนก่อนละกันเนอะ

Configuration ของ Paging

val config = PagedList.Config.Builder()
.setPageSize(pageSize)
.setInitialLoadSizeHint(pageSize * 2)
.setPrefetchDistance(5) .setEnablePlaceholders(false)
.build()

setPageSize(int) คือกำหนด Size ที่โหลดได้มาจากตัว DataSource
setInitialLoadSizeHint(int) คือกำหนดตัวค่าเริ่มต้นว่า จะโหลดมากี่ Item
setPrefetchDistance(int) คือให้โหลดไอเทมที่ไม่แสดงผลก่อนตามจำนวน Item
setEnablePlaceHolders(Boolean) คือให้สร้าง PlaceHolder ตอนก่อนจะเริ่มโหลดหรือไม่

ความแตกต่างระหว่าง

setEnablePlaceHolders(true) vs setEnablePlaceHolders(false)

น่าจะมองเห็นภาพกันเนอะ ว่าต่างกันอย่างไร

Placeholders มีประโยชน์ในการจัดการอย่างไร

  • Support for scrollbars : จำนวนไอเทมทั้งหมด จะสอดคล้องกับ scrollbars
  • ไม่ต้องทำ Load more spinner เพื่อบอก user ว่ามีไอเทมกำลังโหลดเพิ่มเติม

PageList

เป็น Collection List สำหรับ Load Data ทีละก้อน (chunk)โดยหลักการทำงานเบื้องหลังของมันทำงานเป็น Asynchronous ถ้าจะเริ่มต้นใช้งาน หรือ migrate Load More ของโปรเจคเก่าๆ ก็เปลี่ยน Class เป็น PagedList ซะ

class PageListExampleViewModel : ViewModel() {
var userList: LiveData<List<GithubUser>>
}

เปลี่ยนเป็น

class PageListExampleViewModel : ViewModel() {
var userList: LiveData<PagedList<GithubUser>>
}

DataSource

คือ Base Class ของ Paging Library สำหรับ Load Data ส่งไปให้กับ PageList โดยถ้าจะสร้าง DataSource ขึ้นมานั้น จำเป็นต้องมีคลาสแม่ DataSource.Factory ขึ้นมาก่อน

หน้าตา DataSource.Factory

class GithubUserDataSourceFactory(private val githubService: GithubApi) : DataSource.Factory<Long, GithubUser>() {

val usersDataSourceLiveData = MutableLiveData<GithubUserDataSource>()


override fun create(): DataSource<Long, GithubUser> {
val usersDataSource = GithubUserDataSource(githubService)
usersDataSourceLiveData.postValue(usersDataSource)
return usersDataSource
}
}

ประเภทของ DataSource

  • PageKeyedDataSource โหลดแบบ Page (Pagination) โดยตัว API จะต้องส่ง NextPage สำหรับการ Load More เพิ่ม (เว็ปใช้บ่อย)

ตัวอย่าง API PageKeyedDataSource
https://api.github.com/search/repositories?q={search_query}&page={page}&per_page={per_page}

  • ItemKeyedDataSource โหลดแบบ id (เช่น โหลดข้อมูล Comment โดย Load More โดยใช้ ID สุดท้ายของ Item มา fetch ต่อ)

ตัวอย่าง API ItemKeyedDataSource
https://api.github.com/users?since={id}

  • PositionalDataSource

อันนี้ถ้าให้ดูง่าย ก็คงจะคล้ายๆ Contact app บนมือถือ ที่ให้เลื่อนตำแหน่งตัวอักษรที่ต้องการโหลดได้ สำหรับ Blog นี้จะไม่ขออธิบายนะครับ ไว้ว่างๆจะมาอัพเดพให้ฟังเนอะ (ยังไม่เคยใช้งานจริงจัง)

หน้าตา DataSource ประเภท ItemKeyedDataSource

class GithubUserDataSource(
private val githubService: GithubApi,
private val compositeDisposable: CompositeDisposable)
: ItemKeyedDataSource<Long, GithubUser>() {
val networkState = MutableLiveData<NetworkState>() override fun loadInitial(params: LoadInitialParams<Long>, callback: LoadInitialCallback<GithubUser>) {
compositeDisposable.add(githubService.getUsers(1, params.requestedLoadSize).subscribe({ users ->
callback.onResult(users)
}, { throwable ->
throwable.printStackTrace()
}))
}
override fun loadAfter(params: LoadParams<Long>, callback: LoadCallback<GithubUser>) {
//get the users from the api after id
networkState.postValue(NetworkState.LOADING)
compositeDisposable.add(githubService.getUsers(params.key, params.requestedLoadSize).subscribe({ users ->
networkState.postValue(NetworkState.LOADED)
callback.onResult(users)
}, { throwable ->
networkState.postValue(NetworkState.error(throwable.message))
throwable.printStackTrace()
}))
}
override fun loadBefore(params: LoadParams<Long>, callback: LoadCallback<GithubUser>) {
// ignored, since we only ever append to our initial load
}
override fun getKey(item: GithubUser): Long {
return item.id
}
}

หน้าตา DataSource ประเภท PageKeyedDataSource

class PageKeydDataSource(
private val willingApi: WillingApi,
private val compositeDisposable: CompositeDisposable)
: PageKeyedDataSource<String, GithubUser>() {

val networkState = MutableLiveData<NetworkState>()

override fun loadInitial(params: LoadInitialParams<String>, callback: LoadInitialCallback<String, GithubUser>) {
}

override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<String, GithubUser>) {
}

override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<String, GithubUser>) {
// do nothing
}
}

ตัว DataSource ของ ItemKeyedDataSource กับ PageKeyedDataSource จะใช้งานคล้ายๆกัน ดังนี้

สำหรับเริ่มต้นในการโหลดครั้งแรกสุด

เริ่มทำงานเมื่อมีการโหลดไปข้างล่างสุดของ RecyclerView (Load More นั่นเอง)

เริ่มทำงานเมื่อมีการโหลดไปข้างบน

ถามว่า ItemKeyedDataSource กับ PageKeyedDataSource การใช้งานต่างกันมากมั้ย

ตอบตรงๆเลย แทบจะเหมือนกันเลย ต่างกันต้อง LoadCallback ถ้าเป็น ItemKeyedDataSource เราจะต้อง callback กลับ

override fun getKey(item: GithubUser): Long {
return item.id
}

เจ้านี้เป็น Key ที่ไว้สำหรับ bypass ไปยัง method loadInitial , loadAfter , loadBefore

LivePagedListBuilder

ไว้สำหรับจัดการ LiveData ที่จะส่งเข้ามาผ่าน PageList จะใช้งานได ก็ต่อเมื่อมี เรามี DataSource.Factory กับ Configuration

สำหรับใครที่ใช้ Rx แทนการใช้ LiveData นั้น ตัว Library มี RxPagedListBuilder มาใช้แทนได้เลย

โดยทั้งหมดข้างต้น เราจะสามารถจัดการสร้างPaging ให้กับ ViewModel ได้แล้ว

var userList: LiveData<PagedList<GithubUser>>private val pageSize = 10private val sourceFactory: GithubUserDataSourceFactoryinit {
sourceFactory = GithubUserDataSourceFactory(GithubApi.create())
val config = PagedList.Config.Builder()
.setPageSize(pageSize)
.setInitialLoadSizeHint(pageSize * 2)
.setEnablePlaceholders(false)
.build()
userList = LivePagedListBuilder<Long, GithubUser>(sourceFactory, config).build()
}

PagedListAdapter

เป็น Library ที่ Paging Library ให้มา ไว้ช่วยจัดการข้อมูลที่จะแสดงบน RecyclerView โดยมันจะทำการ Update ผ่านคำสั่ง submitList แล้วเดี๋ยวตัว PagedListAdapter จะไปจัดการข้อมูลผ่าน DiffUtil ที่เราสร้างไว้

class PageListExampleAdapter() : PagedListAdapter<GithubUser, RecyclerView.ViewHolder>(UserDiffCallback) {

หลักการทำงานเหมือน Adapter ทุกอย่าง เพียงแค่เราต้องใส่ Dao กับ DiffUtil ให้ Library ตัวนี้สามารถใช้งานได้

viewModel.userList?.observe(this, Observer<PagedList<GithubUser>> {
githubAdapter.submitList(it)
})

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

BoundaryCallback

concept ของมันคือ การนำโดยตัว DataSource ซึ่งข้อมูลที่ไปแสดงนั่นจะเป็นส่วนของ Database เป็นหลัก แต่ส่วนของ Network จะทำการดึง Data มาบันทึกลง Database ตัว Boundary เป็นเหมือน signal ส่งไปบอกตัว Data ว่า ข้อมูลนั่นว่าง หรือมีให้โหลดเพิ่มเติมหรือไม่

วิธีการประกาศใช้งาน

class RepoBoundaryCallback(
private val service: GithubService,
private val cache: GithubLocalCache
) : PagedList.BoundaryCallback<Repo>() {


/**
* Database returned 0 items. We should query the backend for more items.
*/
override fun onZeroItemsLoaded() {
requestAndSaveData()
}

/**
* When all items in the database were loaded, we need to query the backend for more items.
*/
override fun onItemAtEndLoaded(itemAtEnd: Repo) {
requestAndSaveData()
}
}

ตรง LivePagedListBuilder ประกาศ setBoundaryCallback เพิ่มเข้าไป

val data = LivePagedListBuilder(dataSourceFactory)
.setBoundaryCallback(boundaryCallback)
.build()

หลักการหลักๆเลยคือเมื่อเข้ามาในหน้านี้ ข้อมูลจะดึงจาก Database มาก่อนโดย
อัติโนมัติ หากไม่มีข้อมูลเลย มันเข้า method onZeroItemsLoaded() ก่อน แต่ถ้ามีข้อมูลอยู่แล้ว มันจะดึงจาก Database มาโชว์เลย ส่วน onItemAtEndLoaded จะทำงานเมื่อเราเลื่อนมาเจอไอเทมสุดท้ายแล้ว

ตรง BoundaryCallback ยังมีหลายอย่างที่ทำงานซับซ้อนอยู่ ทุกอย่างมันจะดึงจาก Database เป็นหลัก แต่ถ้าหากแอปเราต้องการโหลดข้อมูลใหม่แต่แรกละ .. ตรงจุดนี้เราต้องจัดการกันดีๆด้วย

สรุปหน้าตาของ Paging Library ทั้งหมด

เรื่องนี้ค่อนข้างซับซ้อนอยู่ครับ เพราะเป็นเรื่องที่มีรายละเอียดค่อนข้างเยอะพอสมควร ลองไปทำ CodeLab เพิ่มเติมดีกว่าครับ

https://codelabs.developers.google.com/codelabs/android-paging/index.html#0

ส่วนเจ้าของ Blog นั่นทำตัวอย่างแบบ Network ในรูปแบบ PageKeyedDataSource และ ItemKeyedDataSource ไว้ที่ลิงค์นี้ครับ แต่ใช้ Rx กับ Retrofit มาจัดการนะครับ

Github Example : https://github.com/ult3mate/examplepagelistadapter

หวังว่าคงมีประโยชน์ซักนิดนะครับสำหรับชาว Android ถ้าผิดพลาดก็ขออภัยด้วย :(

Happy coding ครับ

--

--