Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> ระบบ >> Android

เชี่ยวชาญ App Architecture: เลือก Patterns สำหรับการพัฒนาที่น่าเชื่อถือและขยายได้

ซีรีส์ 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 ตามต้องการ
เชี่ยวชาญ App Architecture: เลือก Patterns สำหรับการพัฒนาที่น่าเชื่อถือและขยายได้

Schema MVC: Controller และ View ทั้งคู่ Depend Model Controller Update Data View Get Data Model แยกออกไป Test ได้แยกจาก UI

มีหลาย Approach ใช้ MVC ค่อนข้าง Confusing:

  1. Activities/Fragments เป็น Controller จัดการ Data และ Update Views ปัญหา: ใหญ่ยาก Test
  2. 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
เชี่ยวชาญ App Architecture: เลือก Patterns สำหรับการพัฒนาที่น่าเชื่อถือและขยายได้

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 ตรงๆ ว่าแสดงอะไร

เชี่ยวชาญ App Architecture: เลือก Patterns สำหรับการพัฒนาที่น่าเชื่อถือและขยายได้

MVVM: View มี Reference ไป ViewModel ViewModel ไม่รู้จัก View

Comparison: MVC vs MVP vs MVVM

ตารางสรุป Patterns:

เชี่ยวชาญ App Architecture: เลือก 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:

เชี่ยวชาญ App Architecture: เลือก Patterns สำหรับการพัฒนาที่น่าเชื่อถือและขยายได้

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) ผมผสมทั้งสอง

เชี่ยวชาญ App Architecture: เลือก Patterns สำหรับการพัฒนาที่น่าเชื่อถือและขยายได้
My Crypto Coins App Folder Structure

นอกจาก 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:

  1. Entity - Class บรรจุ Database Row แต่ละ Entity สร้าง Table
  2. DAO (Data Access Object) - Component หลัก Define Methods Access Database
  3. Database - Holder Class 用 Annotation Define Entities, DAOs, Version Main Access Point
เชี่ยวชาญ App Architecture: เลือก Patterns สำหรับการพัฒนาที่น่าเชื่อถือและขยายได้

Setup Room ใน My Crypto Coins:

  1. 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
)
  1. 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

  1. 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 เพื่อทดสอบ

  1. EXTRA: Create Repository

Repository ไม่อยู่ใน Architecture Components เป็น Best Practice สำหรับ Code Separation และ Architecture

เชี่ยวชาญ App Architecture: เลือก Patterns สำหรับการพัฒนาที่น่าเชื่อถือและขยายได้

เป็น 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