ไม่กี่วันที่ผ่านมาเจ้าของบล็อกได้มีโอกาสไปงาน Google Developer Days Europe 2017 ที่ภายในงานได้มี Session เกี่ยวกับ Architecture Components ที่มีเนื้อหาน่าสนใจอยู่ไม่น้อย จึงหยิบมาเขียนเป็นบทความเพื่อเล่าสู่กันฟังครับ
ก่อนจะเริ่ม
บทความนี้จะถือว่าผู้ที่หลงเข้ามาอ่านรู้จักกับ Architecture Components มาก่อนแล้ว ดังนั้นจะไม่มีการเกริ่นเนื้อหาเบื้องต้นนะครับ
Session ดังกล่าวมีชื่อว่า Architecture Components พูดโดย Florina Muntenescu ถ้าอยากดูเนื้อหาแบบละเอียด ก็กดดูจากวีดีโอข้างล่างนี้ได้เลย Architecture Components (GDD Europe '17) [YouTube]
แต่ถ้าขี้เกียจดูวีดีโอ ก็อ่านต่อได้เลย
ทบทวน ViewModel กับ Activity/Fragment Lifecycle
ViewModel นั้นเป็นอะไรที่เจ้าของบล็อกรู้สึกว่าขี้โกงมากๆ เพราะว่ามันสามารถทำงานร่วมกับ Activity/Fragment Lifecycle ได้อย่างมหัศจรรย์ราวกับพระเจ้าเสกมาให้เป็นเนื้อคู่กันเลยก็ว่าได้ โดยที่เราไม่ต้องไปทำอะไรกับมันมากนัก นอกจากการทำความเข้าใจความสัมพันธ์ของเนื้อคู่ชุดนี้
- อยู่รอดทุกครั้งเมื่อเกิด Configuration Changes
- ถูกทำลายเมื่อผู้ใช้กดปุ่ม Back เพื่อออกจาก Activity/Fragment ที่คู่กับ ViewModel นั้นๆ
- ถูกทำลายเมื่อผู้ใช้ปิดแอปจาก Recent Apps
- ถูกทำลายเมื่อแอปถูกย่อและถูกปิด Process ของแอปเพื่อคืน Memory ให้กับระบบ
Avoid reference to Views in ViewModels
ถ้าไม่จำเป็นก็อย่าเอา UI Logic ไว้ใน View เกินจำเป็น พยายามเอาส่วนที่เป็น Logic ไปไว้ใน ViewModel แทน ซึ่งจะช่วยให้สามารถเขียน Unit Test ได้ง่ายขึ้น
โดยให้ ViewModel ดึงข้อมูลที่ต้องการจาก Repository แล้วทำข้อมูลให้พร้อมที่สุดเพื่อส่งไปแสดงผลใน View ได้ทันที ส่วน View ก็แค่ Observe และคอยเรียกคำสั่งต่างๆใน ViewModel เมื่อผู้ใช้ทำอะไรบางอย่าง
ส่วน Repository จะคอยทำหน้าที่ดึงข้อมูลมาให้ ViewModel และจะดึงข้อมูลจาก API หรือว่า Database นั้นก็ขึ้นอยู่กับ Repository ส่วน ViewModel ไม่ต้องมายุ่งเรื่องนี้เลย รอรับข้อมูลอย่างเดียวก็พอ
Add a data repository as the single-point entry to your data
ควรแยก Data Layer กับ Presentation Layer ให้เป็นอิสระต่อจากกัน เพราะว่าการทำงานใน Repository นั้นค่อนข้างจะซับซ้อน ไม่ว่าจะเป็นการจัดการกับข้อมูลใน Database, จัดการกับ Cache หรือการทำงานอื่นๆที่เป็น Synchronous
และเพื่อไม่ให้มีการเรียกข้อมูลจาก API บ่อยจนเกินจำเป็น ควรเก็บข้อมูลไว้ใน Database ด้วยทุกครั้งเพื่อที่จะได้ไม่ต้องไปดึงข้อมูลจาก API ทุกครั้งเมื่อ Activity/Fragment เกิดการ Restore State
ซึ่งในส่วนของ Database ก็สามารถใช้ Room ที่เป็น Object Mapping Library เพื่อช่วยให้จัดการกับ Database ได้ง่ายขึ้น เพราะว่า Room นั้นสามารถทำงานร่วมกับ LiveData ได้ เวลาที่ข้อมูลใน Database มีการเปลี่ยนแปลงเกิดขึ้น LiveData ก็จะแจ้งให้ทันทีเพื่ออัพเดทข้อมูลใหม่
Guide to App Architecture
How many LiveData object should I expose?
ถ้าใน Activity/Fragment นั้นๆมีการแสดงข้อมูลหลายๆอย่าง การแบ่ง ViewModel ตามข้อมูลแต่ละส่วนก็จะช่วยให้จัดการได้ง่ายกว่า รวมไปถึงการเขียนเทสด้วย
What if I’m using MVP? Should I switch to MVVM?
ขึ้นอยู่กับว่า Logic ที่มีและเขียนเทสครอบคลุมไว้มากน้อยแค่ไหน ซึ่งตาม Concept ก็ควรจะเอา Logic ไว้ใน Activity/Fragment ให้น้อยที่สุดเท่าที่จะเป็นไปได้
จะสร้าง ViewModel เพื่ออยู่ระหว่าง Presenter กับ Repository ก็ได้นะ เพื่อทำหน้าที่ติดต่อกับ Repository แทน แล้วค่อยส่งข้อมูลที่ได้ให้ Presenter ไปจัดการเพื่อสั่งงาน View อีกที
แต่เนื่องจาก Presenter ถูกออกแบบมาให้เลี่ยงการเรียกใช้คลาสของ Android Framework จึงทำให้ ViewModel พลอยเรียกใช้ไม่ได้ไปด้วย
Should I use LiveData if I’m already using RxJava?
ถ้าโปรเจคไหนมีใช้ RxJava อยู่แล้ว ก็จะมีลักษณะแบบนี้
RxJava จะถูกใช้งานในทุกๆส่วน ไม่ว่าจะเป็นการทำงานในส่วนที่ติดต่อกับ API, Database และ Repository ก็จะถูกส่งข้อมูลกลับมาในรูปแบบของ Flowable หรือ Observable และ ViewModel ก็จะเป็นแบบนี้เหมือนกัน
แต่การใช้ RxJava ควบคู่กับ LiveData ก็น่าสนใจกว่า โดยแบ่งการทำงานออกเป็น 2 ส่วน ระหว่าง LiveData กับ RxJava โดยใช้ LiveData กับการทำงานในส่วนของ UI เพราะว่า LiveData จะช่วยจัดการกับ Lifecycle ได้ง่ายกว่า ในขณะที่ RxJava ต้องจัดการอะไรหลายๆอย่าง
ถ้ามีการเรียกใช้ Disposable หรือ Subscription ของ RxJava ก็จะอยู่ใน ViewModel แทน และเมื่อ ViewModel ถูกทำลายลง ก็ให้เคลียร์ Disposable หรือ Subscription จากใน ViewModel ได้เลย
Saving Data
แล้วผู้ที่หลงเข้ามาอ่านจะต้องจัดการกับข้อมูลยังไงบ้างล่ะ? ต้องเก็บไว้ใน Database ตอนไหน? และจะต้องเก็บไว้ใน onSavedInstanceState ตอนไหน?
ก่อนจะพูดถึงเรื่องข้อมูลเหล่านั้น มาดูเหตุการณ์ทั้ง 3 แบบที่สามารถเกิดขึ้นกับ ViewModel กันก่อน
แบบที่หนึ่ง : Configuration Changes
ViewModel จะยังคงมีชีวิตอยู่อย่างมีความสุข หลังจากเกิด Configuration Changes แล้วกลับมาทำงานต่ออีกครั้ง เมื่อ ViewModel ที่ถูก Observe ใหม่อีกครั้งก็จะส่งข้อมูลเดิมกลับมาให้ทันที
ดังนั้นในกรณีนี้ ViewModel จะทำงานจบในตัวเอง ไม่มีการเรียกใช้งานอะไรใน Repository เลย
แบบที่สอง : ย่อแอป แล้วเปิดขึ้นมาใช้งานต่อจากเดิม
ในกรณีจะทำให้ onSavedInstanceState
ทำงาน แต่ทว่า ViewModel ไม่ได้ถูกทำลายลง ดังนั้นเมื่อกลับมาเปิดแอปใหม่อีกครั้ง ViewModel ที่ถูก Observe ใหม่ ก็จะส่งข้อมูลเดิมกลับมาให้
แบบที่สาม : ย่อแอป แล้วถูก Kill Process ทิ้งเพื่อคืน Memory ให้กับระบบ
onSavedInstanceState
จะทำงาน และ ViewModel จะถูกทำลายทิ้ง ซึ่งผู้ที่หลงเข้ามาอ่านจะต้องเก็บข้อมูลบางส่วนไว้ใน onSavedInstanceState
(ยกตัวอย่างเช่น Unique ID ของข้อมูลนั้นๆ)
เมื่อแอปกลับมาทำงานใหม่อีกครั้งก็จะมีการ Restore ข้อมูลดังกล่าวขึ้นมา (ยกตัวอย่างเช่น Unique ID ของข้อมูลนั้นๆ) เพื่อส่งให้ ViewModel และส่งต่อให้ Repository เพื่อไป Query ข้อมูลเต็มๆจาก Database อีกทีหนึ่ง (เพราะตอนที่เรียกข้อมูลจาก API จะเก็บลงไว้ใน Database ด้วยทุกครั้ง ทำให้ไม่ต้องไปเรียกข้อมูลจาก API ใหม่)
จัดการกับข้อมูลให้เหมาะสม
เนื่องจาก Database ทำหน้าที่เก็บข้อมูลทั้งหมดไว้ให้แล้ว และ ViewModel ก็จะเก็บข้อมูลแค่ส่วนที่ต้องใช้ใน UI ดังนั้นจึงไม่จำเป็นต้องเก็บข้อมูลก้อนนั้นทั้งหมดไว้ใน onSavedInstanceState
ให้ซ้ำซ้อน แต่ให้เก็บข้อมูลบางส่วนที่ใช้เป็น Reference ในการ Query ข้อมูลใหม่อีกครั้งจาก Database ก็พอ
- Database : เก็บข้อมูลที่อยากให้อยู่ยงคงกระพัน ถึงแม้ว่าจะโดน Kill Process ก็ตาม (มักจะเป็นข้อมูลดิบๆที่ได้จาก API)
- ViewModel : เก็บข้อมูลเฉพาะส่วนที่จำเป็นสำหรับ UI
onSavedInstanceState
: เก็บข้อมูลส่วนที่เล็กที่สุดที่สามารถนำไป Query จาก Database ทีหลังได้ เพื่อใช้ในตอน Restore UI (Restauration Data) จะได้ไม่ต้องไปเรียกข้อมูลจาก API ใหม่อีกครั้ง
Paging Library
ถึงแม้ว่า ViewModel, LiveData, Room หรือ LifecycleOwner จะเข้ามาช่วยให้ควบคุมการทำงานได้ง่ายมากขึ้น แต่แอปพลิเคชันส่วนใหญ่มักจะต้องแสดงข้อมูลจำนวนมากๆด้วย RecyclerView ที่มีการทำงานหลายๆอย่างที่ต้องเขียนเพิ่ม (อย่างการทำ Load More) กลายเป็นว่าการนำ Architecture Components เข้ามาใช้ ก็จะต้องมานั่งคิดรูปแบบการทำงานของโค้ดใหม่อยู่ดี เพื่อให้ RecyclerView สามารถทำงานแบบที่ควรจะเป็นได้
ดังนั้นทางทีมพัฒนาจึงได้เพิ่ม Component ตัวที่ 5 เข้ามาใน Architecture Components นั่นก็คือ Paging เพื่อใช้งานกับ RecyclerView โดยเฉพาะ
โดย Paging จะเข้ามาช่วยจัดการกับการทำงานของ RecyclerView ที่จะต้องคอยโหลดข้อมูลเพิ่มทุกครั้งที่ผู้ใช้เลื่อนดูข้อมูล (Load More) ที่สามารถกำหนดรูปแบบในการแสดงข้อมูลได้เองไม่ว่าจะเป็น Infinite Scrolling หรือว่า Countable Scrolling โดยที่ไม่ต้องไปเขียนโค้ดอะไรเองมากนัก
Paging จะมีคลาสหลักที่ชื่อว่า PagedListAdapter
ซึ่งเป็นคลาสที่สืบทอดมาจาก RecyclerView.Adapter
น่ะแหละ พร้อมกับคลาสอีก 2 ตัวคือ PagedList
และ DataSource
DataSource : Handles incremental data loading
ตัวกลางในการรับข้อมูลจากที่ใดที่หนึ่ง มาส่งต่อให้ PagedList เพื่อนำไปแสดงผลใน RecyclerView ด้วย PagedListAdapter อีกทีหนึ่ง และ DataSource ก็จะคอยจัดการเมื่อต้องการข้อมูลตัวถัดไปด้วย
DataSource จะแบ่งออกเป็น 2 แบบ ซึ่งทำงานคนละแบบ อยู่ที่ว่าต้องการแสดงข้อมูลยังไง
- Keyed DataSource : DataSource ที่ต้องใช้ข้อมูลลำดับที่ N-1 เพื่อดึงข้อมูลที่ลำดับ N
- Tiled DataSource : DataSource ที่สามารถกำหนดตำแหน่งของข้อมูลที่ต้องการได้ทันที
โดย DataSource จะมี Implement Method ดังนี้
fun loadCount(): Int
เป็น Method ที่ใช้ระบุจำนวนข้อมูลที่ต้องการดึงมาแสดงผล สามารถกำหนดเป็นจำนวนที่แน่นอนหรือเป็นจำนวนอนันต์ก็ได้
PagedList
ทำหน้าที่รับข้อมูลต่อจาก DataSource โดยอัตโนมัติผ่าน Background Thread แล้วส่งข้อมูลนั้นๆออกมาเป็น Main Thread (เหยดดดด จัดการให้ด้วย)
ซึ่งรูปแบบในการแสดงข้อมูลก็จะกำหนดผ่าน PagedList ทั้งหมด ไม่ว่าจะเป็น
- แสดงผลเป็นแบบ Infinite Scrolling List หรือ Countable List
- จำนวนข้อมูลที่จะโหลดมาแสดงในตอนเริ่มต้น (Initial load size)
- จำนวนข้อมูลที่จะโหลดมาแสดงเพิ่มในแต่ละครั้ง (Page size)
- ระยะห่างของลำดับข้อมูลตัวสุดท้ายที่จะทำการ Prefetch ข้อมูลไว้ให้ล่วงหน้า (Prefetch distance)
ลำดับการทำงานของ Paging
1. สมมติว่ามีข้อมูลอยู่ก้อนหนึ่ง และแล้วส่งข้อมูลเข้าไปใน DataSource บน Background Thread
2. DataSource จะคอยอัพเดทข้อมูลไปให้ PagedList (ผ่าน Background Thread เช่นกัน)
3. PagedList กำหนดรูปแบบการแสดงข้อมูล แล้วส่งข้อมูลตามรูปแบบนั้นๆ มาเป็น Main Thread
4. PagedList ส่งข้อมูลให้ PagedListAdapter ผ่าน Main Thread
5. PagedListAdapter คำนวณว่าข้อมูลต่างจากข้อมูลตัวเดิมอย่างไร โดยใช้ DiffUtil (ทำงานบน Background Thread)
6. ข้อมูลถูกส่งมาทาง onBindViewHolder
ที่ทำงานบน Main Thread
7. RecyclerView แสดงผลตามข้อมูลที่เปลี่ยนแปลงไป
ซึ่งขั้นตอนทั้งหมดนี้จะทำงานให้อัตโนมัติ โดยที่ผู้ที่หลงเข้ามาอ่านไม่ต้องไปยุ่งอะไรกับมันเลย สิ่งที่ต้องทำก็จะมีแค่การโยนข้อมูลเข้าไปใน DataSource เมื่อมีการอัปเดตเท่านั้นเอง และโค้ดอีกนิดหน่อยเท่านั้น
ซึ่งโค้ดดังกล่าวก็คือคำสั่งในส่วนของ DiffUtil นั่นเอง เพราะว่า DiffUtil ไม่สามารถรู้ได้เองว่าข้อมูลที่ส่งมานั้นเป็นตัวเก่าที่มีการเปลี่ยนค่าหรือว่าเป็นตัวใหม่ที่เพิ่มเข้ามาใหม่ ดังนั้นตรงนี้จึงเป็นหน้าที่ของผู้ที่หลงเข้ามาอ่านที่ต้องกำหนดผ่านโค้ดแบบนี้
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: User, newItem: User) =
oldItem == newItem
}
DiffCallback จะมี Implement Method ด้วยกัน 2 ตัวคือ
areContentsTheSame
: เทียบว่าข้อมูลทั้ง 2 ตัวมีข้อมูลที่เหมือนกันหรือไม่areItemTheSame
: เทียบว่าข้อมูลทั้ง 2 ตัวคือตัวเดียวกันหรือไม่
ซึ่งการเทียบข้อมูลทั้ง 2 ตัวนั้นขึ้นอยู่กับการออกแบบ Model Class ของผู้ที่หลงเข้ามาอ่าน (ในตัวอย่างจะสมมติว่าเป็นคลาสที่สร้างขึ้นมาเองที่ชื่อว่า User ที่ข้อมูลแต่ละตัวจะมี ID ที่ไม่เหมือนกัน)
นอกจากนี้ PagedList นั้น สามารถใช้คลาส LivePagedListProvider แทนได้ ซึ่งเป็น PagedList ที่ครอบด้วย LiveData อีกชั้นหนึ่งเพื่อให้ทำงานร่วมกับ DataSource ที่เป็น LiveData ได้
และนั่นก็หมายความว่ามันสามารถใช้กับ Room ได้ด้วย และถ้าใช้ LivePagedListProvider ควบคู่กับ Room ก็จะสามารถจัดการในส่วนของ DataSource โดยที่ไม่ต้องทำอะไรเพิ่มเติมเลย เพราะว่า Room จะคอยอัปเดตข้อมูลให้ทันทีเมื่อมีการเปลี่ยนแปลง แล้วส่งให้ LivePagedListProviedr ทันที (แล้ว RecyclerView ก็จะแสดงผลตาม Database แทบจะ Realtime กันเลยทีเดียว)
ดังนั้น DataSource ที่เป็น Room เราจึงสามารถใช้ LivePagedListProvider แบบนี้ได้เลย
@Dao
interface UserDao {
@Query("SELECT * FROM user ORDER BY lastName ASC")
fun userByLastName(): LivePagedListProvider<User>
}
เมื่อมาดูคำสั่งใน ViewModel
class MyViewModel(userDao: UserDao) : ViewModel() {
val userList: LiveData<PagedList<User>>
init {
userList = userDao.userByLastName()
.create(Builder()
.setPageSize(50)
.setPrefetchDistance(50)
.build())
}
}
จะเห็นว่าสามารถกำหนดรูปแบบในการดึงข้อมูลจาก Database ได้เลย เพราะ UserDao ส่งข้อมูลกลับมาเป็น LivePagedListProvider นั่นเอง
ส่วนฝั่ง Activity/Fragment ก็สามารถดึงข้อมูลจาก ViewModel มากำหนดใน RecyclerView แบบนี้ได้เลย
class MyActivity : AppCompatActivity() {
public override fun onCreate(savedState: Bundle?) {
val viewModel: MyViewModel = ViewModelProviders.of(this).get(MyViewModel::class.java)
val recyclerView: RecyclerView = findViewById(R.id.user_list)
val adapter: UserAdapter<User> = UserAdapter()
LiveListAdapterUtil.bind(viewModel.userList, this, adapter)
recyclerView.setAdapter(adapter)
}
}
LiveListAdapterUtil จะทำหน้านี้จัดการให้ข้อมูลจาก ViewModel, Adapter และ Lifecycle ทำงานร่วมกันให้โดยอัตโนมัติ ที่เหลือก็เป็นคำสั่งปกติที่รู้ๆกัน
ส่วน Adapter ที่ใช้เป็นคลาส PagedListAdapter ก็จะเรียกใช้งานเหมือน RecyclerView Adapter ทั่วไป เพิ่มเติมแค่กำหนด DiffCallback ไว้ใน Constructor ด้วยเท่านั้นเอง
class UserAdapter : PagedListAdapter<User, UserViewModel>(DIFF_CALLBACK) {
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
val user: User = getItem(position)
holder.bindTo(user)
}
}
Paging ทำให้ผู้ที่หลงเข้ามาอ่านสามารถสร้าง RecyclerView ที่ต้องทำเป็น Pagination ได้ง่ายขึ้น
สรุป
ถือว่าเป็น Session ที่น่าจะตอบคำถามคาใจนักพัฒนาหลายๆคนที่อยากจะรู้แนวทางในการนำ Architecture Components อย่างถูกต้อง รวมไปถึง Component ตัวใหม่อย่าง Paging ที่ช่วยให้ชีวิตง่ายขึ้นไปอีก ที่เหลือก็ต้องดูต่อว่าถ้าจะเอาไปใช้กับโปรเจคของผู้ที่หลงเข้ามาอ่านจะต้องทำอะไรเพิ่มเติมบ้าง ส่งผลกระทบอะไรมั้ย และอย่าลืมเขียนเทสล่ะ ฮาาาา
สุดท้ายนี้ ไม่ว่าจะเป็น Lifecycle, LiveData, PagedList, ViewModel หรือ Room นั้นถูกออกแบบมาให้นำไปใช้งานแค่บางตัวหรือจะใช้งานร่วมกันทั้งหมดก็ได้ ขึ้นอยู่กับว่ารูปแบบโค้ดในโปรเจคของผู้ที่หลงเข้ามาอ่านเป็นแบบไหน เลือกใช้งานให้เหมาะสมครับ