ซีรีส์ Kriptofolio App - ภาค 3
สิ่งสำคัญที่สุดตอนเริ่มสร้างแอปใหม่คือ Architecture ผิดพลาดที่สุดคือไม่มี Architecture Style เลย
หัวข้อ Architecture Choice เป็นที่โต้แย้งใน Android Community หลายปี Google เข้ามามีส่วนร่วม ปี 2017 เปิดตัว Android Architecture Components มาตรฐาน Architecture ทำให้ชีวิต Developers ง่ายขึ้น
บทความนี้จะอภิปรายว่าทำไมต้อง Architect Apps ตัวเลือกมีอะไรบ้าง และวิธีทำ โดยไม่ Reinvent the Wheel ใช้ Guidelines จาก Android Team
บทความนี้เขียนยากที่สุดเพราะขาดความรู้เรื่อง Architecture ต้องศึกษาให้ลึกจนเห็น Big Picture พร้อมแบ่งปันแล้ว
เนื้อหาซีรีส์
- Introduction: Roadmap สร้าง Modern Android App 2018–2019
- Part 1: แนะนำ SOLID Principles
- Part 2: เริ่มสร้าง Android App: Mockups, UI, XML Layouts
- Part 3: Architecture Patterns และการนำไปใช้ (คุณอยู่ที่นี่)
- Part 4: Dependency Injection ด้วย Dagger 2
- Part 5: RESTful Web Services ด้วย Retrofit, OkHttp, Gson, Glide, Coroutines
ทำไมต้องสนใจ App Architecture?
มือใหม่ Android มักเขียน Business Logic ทั้งหมดใน Activities/Fragments Tutorials และ Samples ทุกตัวแนะนำทำแบบนี้ แอปเล็กๆ ทำได้ดีพอ
แต่ลองทำ Real App ที่เปลี่ยนแปลงตามความต้องการ User และเพิ่ม Features อย่างต่อเนื่อง เร็วๆ นี้ Coding Experience จะเจ็บปวด ทุกอย่างถูกควบคุมโดย "God Classes" (Activities/Fragments) โค้ดเยอะมากงงง่าย
โค้ดกลายเป็น Spaghetti ทุกอย่างผสมกัน ทุกส่วนขึ้นต่อกัน ต้อง Rebuild ทั้งโปรเจกต์เมื่อ Business ต้องการเปลี่ยนแปลง นั่นคือจุดที่ Architecture Questions ปรากฏ
มีวิธีโครงสร้างโค้ดที่ดีกว่านี้ไหม?
มีแน่นอน! Key ของ Quality Code คือปฏิบัติตาม SOLID Principles (พูดไปในภาคก่อน ไม่ได้โดยบังเอิญ) และใช้ Architecture Pattern สำหรับ Separation of Concerns จริงๆ แล้ว Separation of Concerns ควรเป็น Ultimate Goal จุดสำคัญที่สุดบ่งชี้ Code Quality
Patterns สำหรับ App Architecture มีหลายตัว สามตัวโดดเด่นคือ Classic Three-tier:
- MVC: Model-View-Controller
- MVP: Model-View-Presenter
- MVVM: Model-View-ViewModel
ทุก Pattern แทนความคิดหลักเดียวกัน: โครงสร้างโค้ดแยกเป็น Generic Layers แต่ละ Layer มี Responsibility ตัวเอง ทำให้ Modular: โค้ดแยกส่วน Testable มากขึ้น App ยืดหยุ่นสำหรับการเปลี่ยนแปลงต่อเนื่อง
Pattern Model-View-Controller (MVC)
Pattern แรกของ Android Architecture แบบเก่า แยกโค้ด 3 Layers:
- Model - Data Layer จัดการ Business Logic และ Communication กับ Network/Database
- View - UI Layer Visualization ข้อมูลจาก Model อย่างง่าย
- Controller - Logic Layer ได้รับแจ้ง User Behavior อัปเดต Model ตามต้องการ
Schema MVC: Controller และ View ทั้งคู่ Depend Model Controller Update Data View Get Data Model แยกออกไป Test ได้แยกจาก UI
มีหลาย Approach ใช้ MVC ค่อนข้าง Confusing:
- Activities/Fragments เป็น Controller จัดการ Data และ Update Views ปัญหา: ใหญ่ยาก Test
- Activities/Fragments เป็น Views Controllers เป็น Classes แยก ไม่ Extend/Android Classes เหมือน Models - Logical และ Correct กว่า
แม้ MVC แบบถูกต้องบน Android Code Layers ยัง Depend กัน ไม่แนะนำให้ใช้สำหรับแอป Android ถัดไป
Pattern Model-View-Presenter (MVP)
หลัง MVC ไม่ได้ผล Android Developers ลอง MVP Pattern ยอดนิยม ยังแนะนำอยู่ เหมาะมือใหม่ เรียนรู้ง่าย ดู 3 Layers:
- Model - Data Layer เหมือน MVC
- View - UI Layer แสดงข้อมูล แจ้ง Presenter User Actions
- Presenter - ดึง Data จาก Model ใช้ UI Logic จัดการ View State ตัดสินใจแสดงอะไร ตอบสนอง User Input จาก View คือ Controller จาก MVC แต่ไม่ผูกติด View แค่ Interface
MVP Schema: View และ Presenter เกี่ยวข้องใกล้ชิด ต้องมี Reference กัน ความสัมพันธ์กำหนดใน Contract Interface
ข้อเสียควบคุมได้: Presenter ใหญ่จัด Class All-knowing หากไม่ระวังและแยก Code ตาม Single Responsibility Principle แต่โดยรวม MVP ให้ Separation of Concerns ดี เป็นตัวเลือกหลักได้
Pattern Model-View-ViewModel (MVVM)
MVVM เป็น Iteration ที่ 3 กลายเป็น Pattern ที่ Android Team แนะนำพร้อม Android Architecture Components Release เน้นเรียนรู้ Pattern นี้มากที่สุด ใช้ใน "My Crypto Coins" App ดู Layers:
- Model - Abstract Data Source ViewModel ทำงานกับ Model Get/Save Data
- View - แจ้ง ViewModel User Actions
- ViewModel - Expose Data Streams ที่เกี่ยวข้องกับ View
ต่างจาก MVP: MVVM ViewModel ไม่ Hold Reference ไป View (Presenter ทำ) ViewModel Expose Event Streams ให้ Views ผูกได้หลายตัว (Many-to-One) MVP: Presenter สั่ง View ตรงๆ ว่าแสดงอะไร
MVVM: View มี Reference ไป ViewModel ViewModel ไม่รู้จัก View
Comparison: MVC vs MVP vs MVVM
ตารางสรุป Patterns:
MVC ไม่ดีเท่า MVP/MVVM สำหรับ Modular Testable Modern App แต่แต่ละ Pattern มี Pros/Cons เลือกได้ถ้าตรงความต้องการ แนะนำศึกษาทุก Pattern คุ้มค่า
ขณะนี้ดำเนินโปรเจกต์ต่อด้วย Trending Pattern 2018 ที่ Google Push - MVVM
Android Architecture Components
ถ้ารู้ Android Lifecycle จะรู้ว่ามัน Headache มากสร้าง App หลีกเลี่ยง Data Flow Problems, Persistence, Stability Issues ระหว่าง Configuration Change
2017 Android Team ตัดสินใจรับผิดชอบ ปล่อย Android Architecture Components Framework แก้ปัญหาเหล่านี้โดยไม่ Complicate Code หรือ Hacks
Android Architecture Components = Libraries ช่วย Design Robust, Testable, Maintainable Apps ปัจจุบันประกอบด้วย:
- Data Binding - Bind Observable Data ไป UI Elements แบบ Declarative
- Lifecycles - Manage Activity/Fragment Lifecycles
- LiveData - Notify Views เมื่อ Database เปลี่ยน
- Navigation - Handle In-app Navigation
- Paging - Load Data 漸進式จาก Data Source
- Room - Fluent SQLite Database Access
- ViewModel - Manage UI-related Data แบบ Lifecycle-conscious
- WorkManager - Manage Background Jobs
ด้วย Android Architecture Components เราจะ Implement MVVM ใน My Crypto Coins ตาม Diagram:
Architecture ที่ Google แนะนำ แสดง Modules 互動กัน ต่อไปครอบเฉพาะ Components ที่ใช้ในโปรเจกต์
จัดระเบียบ Source Files
ก่อนเริ่ม Develop ควรคิดว่า Organize Source Files ยังไง ไม่ทิ้งคำถามนี้ไว้ ไม่งั้น Structure เยอะยากเข้าใจแก้ไข
หลายวิธี: 1) Organize by Component Category (Activities โฟลเดอร์เดียว Adapters โฟลเดอร์เดียว) 2) Organize by App Features (เช่น Feature Add/Search Crypto ไปโฟลเดอร์ addsearchlist) ผมผสมทั้งสอง
นอกจาก Folder Structure ควรกำหนด Naming Rules เช่น Android Classes ชื่อชัดเจน Purpose
ViewModel
เริ่ม App Architecture Development สร้าง ViewModel ก่อน ViewModels = Objects Provide Data ให้ UI Components รอด Configuration Changes
ใช้ ViewModel Retain Data ทั่ว Lifecycle Activity/Fragment Activities/Fragments Short-lived สร้างทำลายบ่อย ViewModel เหมาะสำหรับ Network, Data Manipulation, Persistence
ตัวอย่างสร้าง ViewModel สำหรับ MainListFragment แยก UI Data:
class MainViewModel : ViewModel() {
// ...
}
Obtain ViewModel ด้วย 1 บรรทัด:
class MainListFragment : Fragment() {
// ...
private lateinit var viewModel: MainViewModel
// ...
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setupList()
viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
// ...
}
}
เรียบร้อย! 🎉 ขยับต่อ
LiveData
LiveData = Observable Data Holder Class ตาม Observer Pattern Lifecycle-aware: Update Observers (Activity, Fragment) ที่อยู่ใน Active Lifecycle State เท่านั้น
LiveData Return Latest Value Data เปลี่ยน → Return Updated Value เหมาะกับ ViewModel
ใช้ LiveData กับ ViewModel:
class MainViewModel : ViewModel() {
private val liveData = MutableLiveData>()
val data: LiveData> get() = liveData
init {
val tempData = ArrayList()
val btc = Cryptocurrency("Bitcoin", 1, 0.56822348, "BTC", 8328.77, 4732.60, 0.19, -10.60, 0.44, 20.82)
val eth = Cryptocurrency("Etherium", 2, 6.0, "ETH", 702.99, 4217.94, 0.13, -7.38, 0.79, 33.32)
tempData.add(btc)
tempData.add(eth)
liveData.value = tempData
}
}
Observe Data บน ViewModel ที่ Expose เป็น LiveData:
class MainListFragment : Fragment() {
private lateinit var recyclerView: RecyclerView
private lateinit var recyclerAdapter: MainRecyclerViewAdapter
private lateinit var viewModel: MainViewModel
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setupList()
viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
viewModel.data.observe(this, Observer { data ->
if (data != null) {
recyclerAdapter.setData(data)
}
})
}
}
Data Binding
Data Binding Library สร้างเพื่อลด Boilerplate เชื่อม XML Layouts
ใช้ Data Binding ใน Kotlin Projects ต้องเปิด KAPT Compiler Plugin และเพิ่ม Data Binding Block ใน Gradle:
apply plugin: 'kotlin-kapt'
android {
...
dataBinding {
enabled = true
}
}
ใช้ Data Binding Generated Classes ใส่ View Code ใน <layout> Tags Concept ทรงพลัง: Bind Data Class ไป XML Layout Properties ไป Fields โดยตรง
<layout xmlns:app="https://schemas.android.com/apk/res-auto"
xmlns:tools="https://schemas.android.com/tools">
<data>
<variable name="cryptocurrency" type="com.baruckis.mycryptocoins.data.Cryptocurrency" />
</data>
...
<android.support.v7.widget.AppCompatTextView
android:id="@+id/item_name"
style="@style/MainListItemPrimeText"
android:text="@{cryptocurrency.name}"
... />
...
</layout>
RecyclerView Adapter with Data Binding:
class MainRecyclerViewAdapter() : RecyclerView.Adapter() {
private lateinit var dataList: ArrayList
fun setData(newDataList: ArrayList) {
dataList = newDataList
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = FragmentMainListItemBinding.inflate(inflater, parent, false)
return BindingViewHolder(binding)
}
override fun onBindViewHolder(holder: BindingViewHolder, position: Int) = holder.bind(dataList[position])
override fun getItemCount(): Int = dataList.size
inner class BindingViewHolder(var binding: FragmentMainListItemBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(cryptocurrency: Cryptocurrency) {
binding.cryptocurrency = cryptocurrency
binding.itemRanking.text = String.format("${cryptocurrency.rank}")
// ...
binding.executePendingBindings()
}
}
}
สุดท้ายไม่ต้องเขียน findViewById อีกต่อไป! 🎉
Room
App ต้องเก็บ Persistent Data Cryptocurrencies ที่ User ถืออยู่ ใน Local Database บน Device แบบ Private
เก็บ Structured Data ใน Private Database ใช้ SQLite Database ตัวเลือกดีที่สุด
สร้าง SQLite Database 用 Room Persistence Library ของ Android Team Wrapper บน SQLite Abstraction Layer ลด Boilerplate 互動 SQLite เพิ่ม Compile-time Checking SQL Queries
คิดว่าเป็น ORM (Object Relational Mapper) Auto-generate Glue Code Map Object Instances ↔ Database Rows
Room มี 3 Major Components:
- Entity - Class บรรจุ Database Row แต่ละ Entity สร้าง Table
- DAO (Data Access Object) - Component หลัก Define Methods Access Database
- Database - Holder Class 用 Annotation Define Entities, DAOs, Version Main Access Point
Setup Room ใน My Crypto Coins:
- Create Entity
@Entity
data class Cryptocurrency(
val name: String,
val rank: Short,
val amount: Double,
@PrimaryKey val symbol: String,
val price: Double,
val amountFiat: Double,
val pricePercentChange1h: Double,
val pricePercentChange7d: Double,
val pricePercentChange24h: Double,
val amountFiatChange24h: Double
)
- Create DAO
@Dao
interface MyCryptocurrencyDao {
@Query("SELECT * FROM Cryptocurrency")
fun getMyCryptocurrencyLiveDataList(): LiveData>
@Insert
fun insertDataToMyCryptocurrencyList(data: List)
}
DAO เริ่มต้น: Retrieve Records จาก Table ที่ Entity สร้าง และ Insert Sample Data
- Create และ Setup Database
Database Instance ควร Build ครั้งเดียวต่อ Session ใช้ Singleton Pattern:
@Database(entities = [Cryptocurrency::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun myCryptocurrencyDao(): MyCryptocurrencyDao
companion object {
@Volatile private var instance: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return instance ?: synchronized(this) {
instance ?: buildDatabase(context).also { instance = it }
}
}
private fun buildDatabase(context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
ioThread {
getInstance(context).myCryptocurrencyDao().insertDataToMyCryptocurrencyList(PREPOPULATE_DATA)
}
}
})
.build()
}
val btc = Cryptocurrency("Bitcoin", 1, 0.56822348, "BTC", 8328.77, 4732.60, 0.19, -10.60, 0.44, 20.82)
val eth = Cryptocurrency("Etherium", 2, 6.0, "ETH", 702.99, 4217.94, 0.13, -7.38, 0.79, 33.32)
val PREPOPULATE_DATA = listOf(btc, eth)
}
}
private val IO_EXECUTOR = Executors.newSingleThreadExecutor()
fun ioThread(f: () -> Unit) {
IO_EXECUTOR.execute(f)
}
Initial Run Database จะ Prepopulate Sample Data เพื่อทดสอบ
- EXTRA: Create Repository
Repository ไม่อยู่ใน Architecture Components เป็น Best Practice สำหรับ Code Separation และ Architecture
เป็น Single Source of Truth สำหรับ App Data ทั้งหมด กรณี Multiple Data Sources
class MyCryptocurrencyRepository private constructor(
private val myCryptocurrencyDao: MyCryptocurrencyDao
) {
fun getMyCryptocurrencyLiveDataList(): LiveData> {
return myCryptocurrencyDao.getMyCryptocurrencyLiveDataList()
}
companion object {
@Volatile private var instance: MyCryptocurrencyRepository? = null
fun getInstance(myCryptocurrencyDao: MyCryptocurrencyDao) = instance ?: synchronized(this) {
instance ?: MyCryptocurrencyRepository(myCryptocurrencyDao).also { instance = it }
}
}
}
ใช้ Repository ใน ViewModel:
class MainViewModel(myCryptocurrencyRepository: MyCryptocurrencyRepository) : ViewModel() {
val liveData = myCryptocurrencyRepository.getMyCryptocurrencyLiveDataList()
}
Fragment Code Evolve:
class MainListFragment : Fragment() {
// ...
private lateinit var viewModel: MainViewModel
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setupList()
subscribeUi()
}
private fun subscribeUi() {
val factory = InjectorUtils.provideMainViewModelFactory(requireContext())
viewModel = ViewModelProviders.of(this, factory).get(MainViewModel::class.java)
viewModel.liveData.observe(this, Observer> { data ->
if (data != null && data.isNotEmpty()) {
emptyListView.visibility = View.GONE
recyclerView.visibility = View.VISIBLE
recyclerAdapter.setData(data)
} else {
recyclerView.visibility = View.GONE
emptyListView.visibility = View.VISIBLE
}
})
}
}
ViewModel มี Constructor ไม่ว่าง ต้อง Implement Provider Factory Pattern ส่งให้ ViewModelProviders.of() Parameter ที่ 2
object InjectorUtils {
fun provideMainViewModelFactory(context: Context): MainViewModelFactory {
val repository = getMyCryptocurrencyRepository(context)
return MainViewModelFactory(repository)
}
private fun getMyCryptocurrencyRepository(context: Context): MyCryptocurrencyRepository {
return MyCryptocurrencyRepository.getInstance(AppDatabase.getInstance(context).myCryptocurrencyDao())
}
}
class MainViewModelFactory(private val repository: MyCryptocurrencyRepository) : ViewModelProvider.NewInstanceFactory() {
override fun create(modelClass: Class): T {
return MainViewModel(repository) as T
}
}
Final Thoughts
Design Architectures ที่พูดถึงควรเป็น Informed Guidelines ไม่ใช่ Hard Rules ไม่อยาก Detail แต่ละ Topic จนสับสน ด้วย Android Architecture Components ดู Coding Process จริง แต่แต่ละ Component มีอะไรเรียนรู้เยอะ แนะนำศึกษาต่อ
สรุปสิ่งที่ทำได้แล้ว:
- My Crypto Coins: ทุก Screen มี ViewModel ของตัวเอง รอด Configuration Changes ป้องกัน Data Loss
- UI แบบ Reactive: Update ทันทีเมื่อ Backend Data เปลี่ยน ด้วย LiveData
- Code น้อยลง: Bind Variables โดยตรงด้วย Data Binding
- Store User Data Locally: SQLite Database สร้างสะดวกด้วย Room Code Structure by Features Architecture = MVVM (Google Recommended)
Repository
"Kriptofolio" (เดิม "My Crypto Coins") เริ่มได้รูปทรง Commit ล่าสุด ภาค 3 แสดง Prepopulated Database Data พร้อม Portfolio Value คำนวณถูกต้อง
View Source On GitHub
Ačiū! ขอบคุณที่อ่าน! ประกาศบน Blog ส่วนตัว www.baruckis.com วันที่ 22 สิงหาคม 2018