สับเปลี่ยนกระบวนท่าจาก MVP ไป MVVM และ Coroutine ยังไงไม่ให้แอพพัง

android Aug 14, 2020

ในเมื่อหลายๆที่เขาใช้ MVVM กันแล้ว และทีมอื่นก็ด้วย ทำไมเรายังไม่มุ้ปอรหล่ะ!

สวยขนาดนี้ใครจะมุ้ปอรลงหล่ะ https://www.facebook.com/bnk48official.orn/

ไปเจอโค้ดทีมนึงมา เป็น submodule แล้วมาเปลี่ยนเป็น endpoint เรา เจอ error พวก 404 not found, 401 unauthorized แล้วแอพแคลชเลย เฮ้ยยย มันต้อง handle ไว้ดิ

จากนั้นก็เลยศึกษา structure มาพอประมาณ จนมาลองเองในแอพบล็อกของตัวเอง จนได้วิธีที่เราสามารถเปลี่ยนจาก MVP ไป MVVM ได้แล้วหล่ะ

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

https://www.youtube.com/watch?v=X-xV6hRMHw8

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

ก่อนอื่นมารู้จัก Android Architecture Components กันก่อน

แน่นอนว่าเราเคยเขียนบล็อกไว้ แต่ได้แต่เขียนไง ยังไม่ได้เอาไปทำจริง ก็เลยมีหลงๆลืมๆไปบ้าง

Android Architecture Components คืออะไร แบบม้วนเดียวจบ
Android Developer หลายๆคนเริ่มพูดถึงกันแล้วในประเด็นนี้ กับ Architecture Components อยู่วงการนี้ต้องไวนะ ไม่ว่า Android…

เลยจะมาทบทวนกันสั้นๆสักนิดเนอะ

Architecture Components เป็น best practice ในการพัฒนา Android Application (ซึ่งหมายถึงเราจะทำตามหรือไม่ทำตามก็ได้ แต่ทำเถอะจะได้เป็นมาตรฐานเดียวกันงี้) ปัจจุบันอยู่ใน Android Jetpack อยู่ในส่วน Architecture นั่นเอง

แน่นอนว่าบล็อกเมื่อสามปีที่แล้วยังเป็น Java อยู่ แต่ประเด็นสำคัญจริงๆคือ ก่อนหน้านี้อ่ะ มันเป็น library แยกของตัวเอง ยังไม่ได้มี Jetpack ด้วยนะในตอนนั้น

มาทำความรู้จักแต่ละตัวกันเถอะ ที่จะพูดถึงกันในบล็อกนี้

  • Data Binding มันก็จะมีบางคนที่คิดว่า MVVM ก็คือ data binding จริงๆมันคือ subset ของ MVVM เท่านั้น แค่ภาพจำยางช้าดเจนนนนน เฉยๆ (แนวเดียวกับคนที่รู้จัก BNK48 ก็ต้องรู้จักเฌอปรางอ่ะ)

    ตัวนี้จะทำการ bind observable data ไปที่ view บางตัว เช่น TextView นี้ชื่อว่า textStat จะทำการ observe ว่าถ้ามีค่าของ stat เพิ่มมากก็อัพเดตเพิ่มไปใน View นี้ได้เลย
  • Lifecycles จัดการ lifecycle ของ Activity และ Fragment ช่วยแก้เรื่อง แก้ปัญหาเรื่อง Configuration change แล้วข้อมูลต้องโหลดใหม่ เลยมี ViewModel มาช่วยให้ทำงานได้อย่างต่อเนื่อง ส่วน View ก็อยู่เฉยๆ คอย Observe แล้วก็นำข้อมูลจากใน ViewModel มาแสดง
  • LiveData ทำหน้าที่ observe การเปลี่ยนแปลงของข้อมูลระหว่างหลายๆ components และส่ง update ไปยัง Activity หรือ LifecycleOwner ที่ active
  • Room คอนเซปคือ คือการเก็บ data หรือ cache ใน local พอ offline ก็เก็บ data แล้วส่งตอน online ทีหลัง ทำให้เราไม่ต้องโหลดข้อมูลจาก API ทุกครั้ง โดยใช้ database บน SQLite

    สาเหตุที่ใช้ได้เหมือนกับการเรียก API เพราะว่าใช้ DAO (Data Access object) เหมือนกัน ซึ่งก็น่าจะเป็นพวก data class ที่มีการ declared function equals()/ hashCode() toString() copy() และ componentN() functions โดยที่เราไม่ต้องทำอะไรเลย
Data Classes
We frequently create classes whose main purpose is to hold data. In such a class some standard functionality and…
https://kotlinlang.org/docs/reference/data-classes.html
  • ViewModel จัดการระหว่าง UI กับข้อมูลที่อยู่ใน lifecycle โดยที่เราไม่ต้องไปจัดการการอัพเดตข้อมูลที่ lifecycle เอง
  • WorkManager เป็นตัวจัดการ background service ต่างๆ ซึ่งบล็อกนี้เราจะข้ามมันไปก่อนเนอะ

และการทำงานคร่าวๆมันจะเป็นแบบนี้

สรุปคือขี้เกียจทำรูปใหม่หล่ะ

ก็คือตัว business logic ต่างๆจะไม่ผูกกับ View อีกต่อไป โดยตัว View ในที่นี้คือ Activity หรือ Fragment นั่นเอง มีหน้าที่ในการแสดงผลต่างๆ โดย observe data ต่างๆจาก LiveData ที่มีอยู่ใน ViewModel ซึ่งใน ViewModel จะมี LiveData กี่ตัวก็ได้

และเจ้า ViewModel นี้จะทำการพูดคุยกับ Repository เพื่อดึงข้อมูลบางอย่างไปส่งให้เจ้า View ทั้งหลาย โดยการที่เราเรียก API ด้วย Retrofit นั้น จะอยู่ในส่วน Network นั่นเอง หรือหลายๆคนเรียกมันว่า Data Source แล้วอาจจะเก็บข้อมูลไว้ใน local ก็ได้ผ่านเจ้า Room นั่นเอง แต่ตัวอย่างในบล็อกนี้ยังไม่ไปถึงห้องน้าาา~~

รู้จัก Coroutine กันสักหน่อย

หลังจากที่เราเข้าใจเรื่อง Architecture Components แล้ว มาต่อกันที่เรื่องของ Coroutine แบบสั้นๆกัน

Coroutine มาตอน Kotlin 1.3 เป็น concurrency design pattern ที่ใช้ใน Android ในการ executes พวก asynchronous มาช่วยแก้ปัญหา 2 อย่างด้วยกัน คือ

Manage long-running tasks

จัดการงานที่ทำมาอย่างยาวนาน ซึ่งทำให้ main thread ถูกล็อก และทำให้แอพเราค้าง

ในการสร้าง function ปกติ จะมี invoke หรือ call แล้วก็ return เนอะ ใน Coroutine จะมีเพิ่มมา 2 ตัวด้วยกัน คือ

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

การเรียกใช้ suspend นั้น จะเรียกใช้เฉพาะ function ที่มี suspend ไว้ด้านหน้าเท่านั้น หรือสามารถสร้าง Coroutine ใหม่ ได้ดังนี้

  • launch สร้างใหม่ ไม่ต้อง return อะไรกลับมา move on ได้ทันที เหมาะสำหรับ Regular Function
  • async สร้างใหม่ และให้ return อะไรสักอย่างกลับไปด้วย มักใช้คู่กับ await โดยการใช้งานคร่าวๆนั้น เราใช้ async หน้า Unit และ await ตามหลังตัวแปร ถ้ามีหลายๆตัวสามารถมัดรวมกันเป็น list แล้วต่อท้ายด้วย awaitAll ได้

Use Coroutine for main-safety

ใช้เพื่อความปลอดภัยหลัก ในการเรียก network และ disk operations จาก main thread

ใน Kotlin จะ require เจ้า dispatchers มาให้ 3 ตัวด้วยกัน

  • Dispatchers.Main : ใช้ dispatcher ในการ run main thread เช่น เรียก suspend เพื่อ Android UI framework และ update LiveData
  • Dispatchers.IO : เกี่ยวกับ disk หรือ network ต่างๆ หรือการทำงานอื่นๆที่อยู่นอก main thread เช่น เรียก API ให้อยู่เบื้องหลัง, ให้ Room อ่าน data
  • Dispatchers.Default : อันนี้ทำงานนอก main thread เหมือนกัน เช่น ให้ทำการเรียงข้อมูล และแปลงเป็นเจ้าก้อน JSON

withContext() ใช้ในการสลับ thread ได้ครั้งนึง

CoroutineScope มักใช้กับ ViewModel ใน Android Architecture Components

Improve app performance with Kotlin coroutines | Android Developers
A coroutine is a concurrency design pattern that you can use on Android to simplify code that executes asynchronously…
https://developer.android.com/kotlin/coroutines

version ของ Retrofit ที่รองรับ suspend คือ version 2.6.0 นั่นเอง

จริงๆเรื่อง Coroutine ก็มี codelab ให้ลองทำด้วยนะ

Use Kotlin Coroutines in your Android App
In this codelab you'll learn how to use Kotlin Coroutines in an Android app-a new way of managing background threads…
https://codelabs.developers.google.com/codelabs/kotlin-coroutines/#0

เขาแนะนำให้ใช้กับ Architecture Components ที่มี ViewModel, LiveData, Repository, and Room

การทำงานของ Thread ก็จะมี main thread และใช้ suspend เพื่อแยกตัวออกไปทำงาน แล้ว resume กลับมา main thread อีกทีนึง

ถ้าอยากอ่าน Coroutine ภาษาไทยแบบไวๆสามารถอ่านได้ที่นี่เลย

อธิบายเรื่องการใช้ Kotlin Coroutines ใน Android แบบรวบรัด
ช่วงหลังๆมานี่เพื่อนๆน่าจะเคยได้ยินเรื่องของ Coroutine กันมาเยอะว่ามันดี อย่างนู้นอย่างนี้ แต่ไม่มีเวลาได้ศึกษามันอย่างจริงๆจังๆซักที ในบทความนี้ผมจะพยายามสรุปเรื่องของ Coroutine และ Concept…

มาปรับเปลี่ยนกระบวนท่ากันเป็น MVVM กันเถอะ

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

บันทึกการ Redesign แอพอ่านบล็อกของตัวเอง ให้เป็น Material Design
เนื่องจากเราเองก็อยากลอง redesign แอพอ่านบล็อกตัวเอง จนย้ายบล็อกมาที่ Ghost แล้ว จึงต้อง Redesign แอพอ่านบล็อก MikkiPastel ใหม่

มาเริ่มทำกันเลย!~

ก่อนอื่นเพิ่ม dependency ต่างๆที่ต้องใช้กันก่อนนะ (เลข version ก็ใส่ล่าสุดที่ stable แล้วไปเนอะ คนอ่านอาจจะต้องไปอัพเดต version เอง 555)

implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.6'

ต่อมาประกาศพวก API ต่างๆ จาก Gateway สู่ Network โดยใช้ suspend และไม่ต้อง return Call<T> อีกต่อไป

ปล. ใน gist มันเรียงชื่อไฟล์ตามตัวอักษรหง่ะ ของเก่าคือ ApiService.kt และของใหม่คือ ApiNetwork.kt

เรียกใช้ API ต่างๆที่ Repository และใส่ Coroutine ลงไปด้วย จะคล้ายๆ class presenter แหละ แบ่งเป็นส่วน interface และ implement ซึ่งจะเอาไปใส่ใน Koin ต่อไป

เอา response data ไปใส่ใน ViewModel และใส่ Coroutine ลงไปด้วย อย่าลืม handle ตอน error ด้วยนะ จริงๆจะมีประมาณ 2 แบบหลักๆ (ยังไม่พูดถึง UseCase นะ)

แบบแรก คือ ใส่ CoroutineExceptionHandler เข้าไป เป็น parameter หลัง launch หรือ async ถ้าเราเรียก API นั้นไม่สำเร็จด้วยสาเหตุใดก็ตาม จะไปทำใน exception handler ตัวนี้ โดยตอนแรกที่เราเจอ crash จากอีกทีม หรือไม่ได้ handle error ตรงนี้อ่ะ

หรืออีกแบบก็คือแบบบ้านๆสามัญธรรมดา คือใส่ try-catch เข้าไปนั่นเอง

สามารถดูตามในนี้ได้เลยเนอะ

Exception Handling
This section covers exception handling and cancellation on exceptions. We already know that cancelled coroutine throws…
https://kotlinlang.org/docs/reference/coroutines/exception-handling.html

ค่าต่างๆที่ได้จาก API นั้น มักจะเก็บไว้ใน LiveData และทำการนำไปใช้ต่อ โดยผ่านเจ้า ViewModel ที่เรา observe LiveData อีกทีนี่แหละ

แต่ๆ อ่านหัวข้อต่อไปดีกว่าเนอะ

Dependency Injection ของมันต้องมี

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

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

แฉหมดเปลือก! Dependency Injection ไม่ได้ยาก แค่ Dagger แม่งงง
เรื่อง DI นี่ผมเล็งจะเขียนนานละ แต่มันเป็นเรื่องที่คลุมเครือมากๆ คือตอนแรกที่ผมหัดใช้ Dagger ก็แค่เปิด doc ทำตาม ลองผิดลองถูกจนสำเร็จใช้งานได้ แต่ไม่ได้เข้าใจ มันงงอะ…
https://blacklenspub.com/demystify-di-th-d08b3276702d
มาเปลี่ยน Dependency Injection ให้เป็นเรื่องง่ายด้วย Koin กันดูมั้ย?
ถ้าพูดถึง Dependency Injection บนแอนดรอยด์ก็จะนึกถึง Dagger 2 เป็นอย่างแรก เพราะว่าเป็น Dependency Injection Framework ตัวแรกๆที่ออกมาใช้กัน
https://akexorcist.dev/koin-the-lightweight-dependency-injection-framework-for-kotlin/
เข้าสู่ยุคของ Koin Dependency Injection framework สำหรับ Kotlin
Dependency ในมุมของการเขียนโปรแกรมคือการเขียนโค้ดที่มีการพึ่งพาอาศัยกันของ Object ยกตัวอย่างเช่น รถมอเตอร์ไซค์(Bike) จะสามารถ Start ได้ต้องมีน้ำมัน(Fuel) เป็นส่วนประกอบ ซึ่งหมายความว่า Bike…
https://blog.appsynth.net/https-medium-com-jeeraphan-lairat-sample-koin-39a6ad3b539e

สำหรับ Document ของ Koin สามารถเข้าไปอ่านได้ที่นี่

insert-koin.io
a smart Kotlin dependency injection framework
https://insert-koin.io/

และแน่นอนคือ Koin นั้น ไม่ใช่ Dependency Injection นางเป็นเพียง Service Locator ที่ทำหน้าที่เหมือน Dependency Injection อ่ะ ถ้าของใหม่กว่านั้นก็คือ Hilt ที่แก้ pain จากความใช้ยากของ Dagger2 โดยการครอบ Dagger2 อีกที…

ไหนๆพูดถึง Hilt แล้วขอแปะวิดีโอไว้หน่อยเนอะ
https://www.youtube.com/watch?v=B56oV3IHMxg

เราจะมาอธิบายสั้นๆเกี่ยวกับการใช้ Koin ในที่นี้กันเนอะ

ก่อนอื่นเรามาเพิ่ม dependency ของเจ้า Koin กันก่อน

implementation "org.koin:koin-android-viewmodel:2.1.5"
implementation "org.koin:koin-androidx-ext:2.1.5"

จากนั้นเข้าไป init ที่ Application class กันก่อน

  • androidLogger() ใช้ AndroidLogger เป็น Koin Logger เอาไว้ log นั่นแหละ
  • androidContext(this) ใช้ context เฉพาะในแอพเราเท่านั้น โดยจะรับ context ในระดับ Application เลยจ้า
  • module อันนี้อาจจะมีความจุกจิกบ้าง เป็น list ของ module ที่เราต้องการใช้ DI ในที่นี้จะแบ่งเป็น 2 ส่วน ดังนี้

    1) networkModule เอาไว้เพื่อกำหนด service ที่เราต้องใช้

    2) blogModule เอาไว้เพื่อ declare เจ้า Repository และ ViewModel ที่เราต้องการใช้

เรามักจะใช้เจ้า Koin เพื่อเอา ViewModel ของเราไปใช้ในส่วนของ View นั่นเอง การเรียกใช้ก็แสนจะง่าย เป็นท่า Lazy Inject ViewModel

private val blogViewModel: BlogViewModel by viewModel()

ส่วนอีกท่านึง เรียกแบบตรงๆไปเลย

val blogViewModel : BlogViewModel = getViewModel()

จากนั้นก็เอาเจ้า ViewModel ที่ได้ ไปใช้งานต่อไป

ในเมื่อ ViewModel เป็นที่ที่มี Logic เยอะที่สุด ดังนั้นก็ต้องใช้ LiveData ในการ Observe สิ

แต่โค้ดมันดูแปลกๆเนอะ สำหรับคนที่ใช้ MVVM แล้วมาอ่านบล็อกนี้คงอิหยังวะ เพราะ เรายังไม่ได้พูดถึงสิ่งเรียกว่า LiveData นั่นเอง โดยใน ViewModel 1 ตัว สามารถมีได้หลาย LiveData และการนำค่าต่างๆไปใช้ใน View ก็จะทำการ observer เจ้าก้อน LiveData นั่นเอง

ก่อนอื่นเอาแบบง่ายๆ เอา list blog ที่ได้ไปเป็น LiveData ซะ ขอแปะโค้ดเต็มก่อนแล้วค่อยอธิบายเนอะ

ใน class ของ ViewModel จริงๆก็จะมีได้หลาย LiveData เนอะ โดยในที่นี้จะเป็นการดึงบล็อกทั้งหมดหรือตาม hashtag จาก Ghost API มาแสดง โดยจะรับตัว response result เป็น MutableLiveData<T>() ในกรณีที่มัน success จะนำค่า _allBlogPost ไป set .value เป็น response ตัวที่เราต้องการ อย่างในที่นี้ก็จะเป็น post ทั้งหมดเนอะ

แต่ถ้ามันไม่ success แล้วเราไม่ได้ดัก CoroutineExceptionHandler แอพก็พังเนอะตามที่กล่าวไป และถ้าดักแล้ว เราสามารถ assign ค่าบางอย่าง เช่น ถ้ามัน error ให้แสดง UI ชุดนี้ขึ้นมานะ จึง assign เป็น Unit แบบนี้ _getBlogError.value = Unit

ด้วยความที่เราเปลี่ยนให้มันเป็น MutableList ของ BlogPost ดังนั้นตัว function เราจะเปลี่ยนชื่อ function จาก onGetPostSuccess ไปเป็น showBlogContent แทน

การนำเจ้า LiveData ไปเรียกใช้นั้น การเรียก service ต่างๆ ถ้าใน Fragment จะเรามักจะให้เรียกใช้ที่ onActivityCreated() เพราะให้เรียก API ตอนที่ Activity นั้นสร้างเสร็จ (onCreate() ) เลยโดยเราสร้าง function ที่ชื่อว่า loadPostData() เพื่อเอาไปใช้งานในหลายๆที่ เช่น ดึงบล็อกทั้งหมดมาในตอนแรก ดึงบล็อกตาม hashtag ที่เลือกไว้

override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        loadPostData(null)
}
    
private fun loadPostData(hashtag: String?) {
        blogViewModel.getBlogPost(mPage, hashtag)
}
ความแตกต่างของ onCreate(), onCreateView() และ onActivityCreated() ใน Fragment ต่างกันอย่างไร อ่าน top answer แล้วเข้าใจง่ายดีอ่ะ
Difference and uses of onCreate(), onCreateView() and onActivityCreated() in fragments
What are the differences between onCreate(), onCreateView(), and onActivityCreated() in fragments and what would they each be used for?

และพวกการแสดงผลต่างๆ เราจะ Observer ค่าของ LiveData จาก ViewModel ที่ onViewCreated()

blogViewModel.allBlogPost.observe(viewLifecycleOwner, Observer {
    showBlogContent(it)
})

ส่วนใน Activity นั้นมักจะเรียก API และ Observe LiveData ที่ onCreate() นะ ก็จะประมาณนี้แหละ

โค้ดทั้งหมดสามารถดูได้ที่นี่จ้า

mikkipastel/MikkiPastel
Application for read MikkiPastel’s blog in android - mikkipastel/MikkiPastel

เฮ้อออออเขียนเสร็จสักทีเนอะ


ส่วนการเทสต่างๆ ไปอ่านในนี้ได้เลยจ้า

มาเขียนเทสกัน กับงาน Android Codelabs Together ตอน Testing Basics
ฝั่ง Firebase เขาก็มีกันไปแล้ว เป็น Codelab เขียนเว็บ แล้วฝั่ง Android จะยอมน้อยหน้ากันได้อย่างไรจริงม่ะ
สร้าง Repository ใน MVVM บนแอนดรอยด์ให้เขียนเทสได้ง่ายกันเถอะ
ถ้าจะต้องเขียนแอปขึ้นมาใหม่ซักตัวหนึ่ง และต้องเลือก Structure Pattern ในโปรเจคนั้นๆ ส่วนใหญ่ก็คงจะเลือก MVVM กัน เพราะว่าเป็น Pattern ที่ค่อยข้างได้รับความนิยมและการสนับสนุนจากทีมพัฒนาแอนดรอยด์มากที่สุดเลยก็ว่าได้ และยิ่งนำ Clean Architecture เข้ามาใช้ด้วยแล้วก็ยิ่งทำให้โค้ดนั้นดูดีมากขึ้นไปอีก

ผลที่ได้ เราก็สามารถเรียกบล็อกทั้งหมดได้เหมือนเดิมแล้วนะ ที่เรามองไม่เห็นก็คือโครงสร้างโค้ดด้านในนั่นเอง แฮร่~~

ปล. คราวหน้าอาจจะพูดถึง Paging มั้งนะๆ


สุดท้าย เขียนบล็อกเสร็จแล้ว ฝากร้านได้ เย้ๆ

อย่าลืมกด like กด share บทความกันด้วยนะคะ :)

Posted by MikkiPastel on Sunday, 10 December 2017

Tags

Minseo Chayabanjonglerd

Android Developer ผู้เป็นเจ้าของบล็อก MikkiPastel ที่ชอบทำหลายๆอย่างนอกจากเขียนแอพแอนดรอยด์ เช่น เขียนบล็อก เขียนแชทบอท เรียนออนไลน์ อ่านหนังสือ วาดรูปเล่น ดู netfilx สั่งอาหารอร่อยๆกัน เป็นต้น

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.