แอพ Android ที่ดีกว่าโดยใช้ MVVM พร้อมสถาปัตยกรรมที่สะอาด

เผยแพร่แล้ว: 2022-03-11

หากคุณไม่ได้เลือกสถาปัตยกรรมที่เหมาะสมสำหรับโปรเจ็กต์ Android ของคุณ คุณจะมีความยากลำบากในการบำรุงรักษาเมื่อ Codebase ของคุณเติบโตขึ้นและทีมของคุณก็ขยายตัว

นี่ไม่ใช่แค่บทช่วยสอน Android MVVM ในบทความนี้ เราจะรวม MVVM (Model-View-ViewModel หรือ "รูปแบบ ViewModel" ที่เก๋มีสไตล์) เข้ากับ Clean Architecture เราจะมาดูกันว่าสถาปัตยกรรมนี้สามารถใช้เขียนโค้ดที่แยกอิสระ ทดสอบได้ และบำรุงรักษาได้อย่างไร

ทำไมต้อง MVVM ด้วยสถาปัตยกรรมที่สะอาด?

MVVM Fragment มุมมองของคุณ (เช่น Activity และ ส่วนย่อย) ออกจากตรรกะทางธุรกิจของคุณ MVVM เพียงพอสำหรับโปรเจ็กต์ขนาดเล็ก แต่เมื่อโค้ดเบสของคุณมีขนาดใหญ่ขึ้น ViewModel ของคุณก็เริ่มบวมขึ้น การแยกความรับผิดชอบกลายเป็นเรื่องยาก

MVVM ที่มี Clean Architecture นั้นค่อนข้างดีในกรณีเช่นนี้ ก้าวไปอีกขั้นในการแยกความรับผิดชอบของฐานรหัสของคุณ มันสรุปตรรกะของการกระทำที่สามารถทำได้ในแอปของคุณอย่างชัดเจน

หมายเหตุ: คุณสามารถรวมสถาปัตยกรรมที่สะอาดเข้ากับสถาปัตยกรรม model-view-presenter (MVP) ได้เช่นกัน แต่เนื่องจาก Android Architecture Components มีคลาส ViewModel ในตัวอยู่แล้ว เราจึงเลือกใช้ MVVM ผ่าน MVP โดยไม่ต้องใช้เฟรมเวิร์ก MVVM!

ข้อดีของการใช้สถาปัตยกรรมที่สะอาด

  • รหัสของคุณสามารถทดสอบได้ง่ายกว่า MVVM ธรรมดา
  • รหัสของคุณถูกแยกออกเพิ่มเติม (ข้อได้เปรียบที่ใหญ่ที่สุด)
  • โครงสร้างแพ็คเกจนั้นง่ายต่อการนำทาง
  • โครงการนี้ง่ายต่อการบำรุงรักษา
  • ทีมของคุณสามารถเพิ่มคุณสมบัติใหม่ได้รวดเร็วยิ่งขึ้น

ข้อเสียของสถาปัตยกรรมที่สะอาด

  • มีเส้นโค้งการเรียนรู้ที่สูงชันเล็กน้อย การทำงานร่วมกันของเลเยอร์ทั้งหมดอาจต้องใช้เวลาพอสมควรในการทำความเข้าใจ โดยเฉพาะอย่างยิ่งหากคุณมาจากรูปแบบเช่น MVVM ธรรมดาหรือ MVP
  • มันเพิ่มคลาสพิเศษจำนวนมาก ดังนั้นจึงไม่เหมาะสำหรับโครงการที่มีความซับซ้อนต่ำ

การไหลของข้อมูลของเราจะมีลักษณะดังนี้:

การไหลของข้อมูลของ MVVM ด้วย Clean Architecture ข้อมูลจาก View ไปยัง ViewModel ไปยัง Domain ไปยัง Data Repository จากนั้นไปยังแหล่งข้อมูล (Local หรือ Remote)

ตรรกะทางธุรกิจของเราแยกออกจาก UI โดยสิ้นเชิง ทำให้โค้ดของเราง่ายต่อการบำรุงรักษาและทดสอบ

ตัวอย่างที่เราจะไปดูกันนั้นค่อนข้างง่าย อนุญาตให้ผู้ใช้สร้างโพสต์ใหม่และดูรายการโพสต์ที่สร้างโดยพวกเขา ฉันไม่ได้ใช้ไลบรารีของบุคคลที่สาม (เช่น Dagger, RxJava เป็นต้น) ในตัวอย่างนี้เพื่อความเรียบง่าย

เลเยอร์ของ MVVM พร้อมสถาปัตยกรรมที่สะอาด

รหัสแบ่งออกเป็นสามชั้นแยกกัน:

  1. ชั้นนำเสนอ
  2. เลเยอร์โดเมน
  3. ชั้นข้อมูล

เราจะดูรายละเอียดเพิ่มเติมเกี่ยวกับแต่ละเลเยอร์ด้านล่าง สำหรับตอนนี้ โครงสร้างแพ็คเกจผลลัพธ์ของเรามีลักษณะดังนี้:

MVVM พร้อมโครงสร้างแพ็คเกจ Clean Architecture

แม้ภายในสถาปัตยกรรมแอป Android ที่เราใช้อยู่ มีหลายวิธีในการจัดโครงสร้างลำดับชั้นของไฟล์/โฟลเดอร์ของคุณ ฉันชอบจัดกลุ่มไฟล์โครงการตามคุณสมบัติ ฉันคิดว่ามันเรียบร้อยและรัดกุม คุณมีอิสระในการเลือกโครงสร้างโครงการที่เหมาะสมกับคุณ

เลเยอร์การนำเสนอ

ซึ่งรวมถึง Activity s, Fragment s และ ViewModel s ของเรา Activity ควรเป็นใบ้ให้มากที่สุด อย่าใส่ตรรกะทางธุรกิจของคุณใน Activity s

Activity จะพูดคุยกับ ViewModel และ ViewModel จะพูดคุยกับเลเยอร์ของโดเมนเพื่อดำเนินการต่างๆ ViewModel ไม่เคยพูดคุยกับชั้นข้อมูลโดยตรง

ที่นี่เรากำลังส่ง UseCaseHandler และ UseCase สองรายการไปยัง ViewModel ของเรา เราจะพูดถึงรายละเอียดเพิ่มเติมในไม่ช้านี้ แต่ในสถาปัตยกรรมนี้ UseCase คือการดำเนินการที่กำหนดวิธีที่ ViewModel โต้ตอบกับชั้นข้อมูล

นี่คือลักษณะของโค้ด Kotlin ของเรา:

 class PostListViewModel( val useCaseHandler: UseCaseHandler, val getPosts: GetPosts, val savePost: SavePost): ViewModel() { fun getAllPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { val requestValue = GetPosts.RequestValues(userId) useCaseHandler.execute(getPosts, requestValue, object : UseCase.UseCaseCallback<GetPosts.ResponseValue> { override fun onSuccess(response: GetPosts.ResponseValue) { callback.onPostsLoaded(response.posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } fun savePost(post: Post, callback: PostDataSource.SaveTaskCallback) { val requestValues = SavePost.RequestValues(post) useCaseHandler.execute(savePost, requestValues, object : UseCase.UseCaseCallback<SavePost.ResponseValue> { override fun onSuccess(response: SavePost.ResponseValue) { callback.onSaveSuccess() } override fun onError(t: Throwable) { callback.onError(t) } }) } }

โดเมนเลเยอร์

เลเยอร์โดเมนประกอบด้วย กรณีการใช้งาน ทั้งหมดของแอปพลิเคชันของคุณ ในตัวอย่างนี้ เรามี UseCase ซึ่งเป็นคลาสนามธรรม UseCase ทั้งหมดของเราจะขยายคลาสนี้

 abstract class UseCase<Q : UseCase.RequestValues, P : UseCase.ResponseValue> { var requestValues: Q? = null var useCaseCallback: UseCaseCallback<P>? = null internal fun run() { executeUseCase(requestValues) } protected abstract fun executeUseCase(requestValues: Q?) /** * Data passed to a request. */ interface RequestValues /** * Data received from a request. */ interface ResponseValue interface UseCaseCallback<R> { fun onSuccess(response: R) fun onError(t: Throwable) } }

และ UseCaseHandler จะจัดการการดำเนินการของ UseCase เราไม่ควรบล็อก UI เมื่อเราดึงข้อมูลจากฐานข้อมูลหรือเซิร์ฟเวอร์ระยะไกลของเรา นี่คือที่ที่เราตัดสินใจเรียกใช้ UseCase ของเราบนเธรดพื้นหลัง และรับการตอบกลับบนเธรดหลัก

 class UseCaseHandler(private val mUseCaseScheduler: UseCaseScheduler) { fun <T : UseCase.RequestValues, R : UseCase.ResponseValue> execute( useCase: UseCase<T, R>, values: T, callback: UseCase.UseCaseCallback<R>) { useCase.requestValues = values useCase.useCaseCallback = UiCallbackWrapper(callback, this) mUseCaseScheduler.execute(Runnable { useCase.run() }) } private fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mUseCaseScheduler.notifyResponse(response, useCaseCallback) } private fun <V : UseCase.ResponseValue> notifyError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mUseCaseScheduler.onError(useCaseCallback, t) } private class UiCallbackWrapper<V : UseCase.ResponseValue>( private val mCallback: UseCase.UseCaseCallback<V>, private val mUseCaseHandler: UseCaseHandler) : UseCase.UseCaseCallback<V> { override fun onSuccess(response: V) { mUseCaseHandler.notifyResponse(response, mCallback) } override fun onError(t: Throwable) { mUseCaseHandler.notifyError(mCallback, t) } } companion object { private var INSTANCE: UseCaseHandler? = null fun getInstance(): UseCaseHandler { if (INSTANCE == null) { INSTANCE = UseCaseHandler(UseCaseThreadPoolScheduler()) } return INSTANCE!! } } }

ตามชื่อของมัน GetPosts UseCase มีหน้าที่รับผิดชอบในการรับโพสต์ทั้งหมดของผู้ใช้

 class GetPosts(private val mDataSource: PostDataSource) : UseCase<GetPosts.RequestValues, GetPosts.ResponseValue>() { protected override fun executeUseCase(requestValues: GetPosts.RequestValues?) { mDataSource.getPosts(requestValues?.userId ?: -1, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { val responseValue = ResponseValue(posts) useCaseCallback?.onSuccess(responseValue) } override fun onError(t: Throwable) { // Never use generic exceptions. Create proper exceptions. Since // our use case is different we will go with generic throwable useCaseCallback?.onError(Throwable("Data not found")) } }) } class RequestValues(val userId: Int) : UseCase.RequestValues class ResponseValue(val posts: List<Post>) : UseCase.ResponseValue }

วัตถุประสงค์ของ UseCase คือการเป็นตัวกลางระหว่าง ViewModel ของคุณและ Repository

สมมติว่าในอนาคตคุณตัดสินใจเพิ่มคุณลักษณะ "แก้ไขโพสต์" สิ่งที่คุณต้องทำคือเพิ่ม EditPost UseCase และโค้ดทั้งหมดจะถูกแยกและแยกออกจาก UseCase อื่นๆ โดยสิ้นเชิง เราเคยเห็นมาหลายครั้งแล้ว: มีการแนะนำคุณลักษณะใหม่และพวกเขาทำลายบางสิ่งในโค้ดที่มีอยู่ก่อนโดยไม่ได้ตั้งใจ การสร้าง UseCase แยกต่างหากช่วยหลีกเลี่ยงสิ่งนั้นได้อย่างมาก

แน่นอน คุณไม่สามารถกำจัดความเป็นไปได้นั้นได้ 100 เปอร์เซ็นต์ แต่คุณสามารถย่อให้เล็กสุดได้อย่างแน่นอน นี่คือสิ่งที่แยก Clean Architecture ออกจากรูปแบบอื่นๆ: โค้ดถูกแยกออกมากจนคุณถือว่าทุกเลเยอร์เป็นกล่องดำได้

ชั้นข้อมูล

มีที่เก็บทั้งหมดที่เลเยอร์โดเมนสามารถใช้ได้ เลเยอร์นี้เปิดเผยแหล่งข้อมูล API แก่คลาสภายนอก:

 interface PostDataSource { interface LoadPostsCallback { fun onPostsLoaded(posts: List<Post>) fun onError(t: Throwable) } interface SaveTaskCallback { fun onSaveSuccess() fun onError(t: Throwable) } fun getPosts(userId: Int, callback: LoadPostsCallback) fun savePost(post: Post) }

PostDataRepository ใช้ PostDataSource มันตัดสินใจว่าเราดึงข้อมูลจากฐานข้อมูลท้องถิ่นหรือเซิร์ฟเวอร์ระยะไกล

 class PostDataRepository private constructor( private val localDataSource: PostDataSource, private val remoteDataSource: PostDataSource): PostDataSource { companion object { private var INSTANCE: PostDataRepository? = null fun getInstance(localDataSource: PostDataSource, remoteDataSource: PostDataSource): PostDataRepository { if (INSTANCE == null) { INSTANCE = PostDataRepository(localDataSource, remoteDataSource) } return INSTANCE!! } } var isCacheDirty = false override fun getPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { if (isCacheDirty) { getPostsFromServer(userId, callback) } else { localDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { getPostsFromServer(userId, callback) } }) } } override fun savePost(post: Post) { localDataSource.savePost(post) remoteDataSource.savePost(post) } private fun getPostsFromServer(userId: Int, callback: PostDataSource.LoadPostsCallback) { remoteDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() refreshLocalDataSource(posts) callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } private fun refreshLocalDataSource(posts: List<Post>) { posts.forEach { localDataSource.savePost(it) } } private fun refreshCache() { isCacheDirty = false } }

รหัสส่วนใหญ่อธิบายตนเองได้ คลาสนี้มีสองตัวแปร localDataSource และ remoteDataSource ประเภทของพวกเขาคือ PostDataSource ดังนั้นเราจึงไม่สนใจว่าพวกเขาจะถูกนำไปใช้จริงภายใต้ประทุนอย่างไร

จากประสบการณ์ส่วนตัวของฉัน สถาปัตยกรรมนี้ได้รับการพิสูจน์แล้วว่าล้ำค่า ในหนึ่งในแอพของฉัน ฉันเริ่มต้นด้วย Firebase ที่ส่วนหลัง ซึ่งเหมาะสำหรับการสร้างแอปของคุณอย่างรวดเร็ว ฉันรู้ว่าในที่สุดฉันต้องเปลี่ยนไปใช้เซิร์ฟเวอร์ของตัวเอง

เมื่อฉันทำ ทั้งหมดที่ฉันต้องทำคือเปลี่ยนการใช้งานใน RemoteDataSource ฉันไม่ต้องแตะต้องชั้นเรียนอื่นแม้หลังจากการเปลี่ยนแปลงครั้งใหญ่เช่นนี้ นั่นคือข้อดีของรหัสแบบแยกส่วน การเปลี่ยนคลาสที่กำหนดไม่ควรส่งผลกระทบต่อส่วนอื่นๆ ของโค้ดของคุณ

คลาสพิเศษบางคลาสที่เรามี ได้แก่:

 interface UseCaseScheduler { fun execute(runnable: Runnable) fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) } class UseCaseThreadPoolScheduler : UseCaseScheduler { val POOL_SIZE = 2 val MAX_POOL_SIZE = 4 val TIMEOUT = 30 private val mHandler = Handler() internal var mThreadPoolExecutor: ThreadPoolExecutor init { mThreadPoolExecutor = ThreadPoolExecutor(POOL_SIZE, MAX_POOL_SIZE, TIMEOUT.toLong(), TimeUnit.SECONDS, ArrayBlockingQueue(POOL_SIZE)) } override fun execute(runnable: Runnable) { mThreadPoolExecutor.execute(runnable) } override fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mHandler.post { useCaseCallback.onSuccess(response) } } override fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mHandler.post { useCaseCallback.onError(t) } } }

UseCaseThreadPoolScheduler มีหน้าที่รับผิดชอบในการทำงานแบบอะซิงโครนัสโดยใช้ ThreadPoolExecuter

 class ViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { if (modelClass == PostListViewModel::class.java) { return PostListViewModel( Injection.provideUseCaseHandler() , Injection.provideGetPosts(), Injection.provideSavePost()) as T } throw IllegalArgumentException("unknown model class $modelClass") } companion object { private var INSTANCE: ViewModelFactory? = null fun getInstance(): ViewModelFactory { if (INSTANCE == null) { INSTANCE = ViewModelFactory() } return INSTANCE!! } } }

นี่คือ ViewModelFactory ของเรา คุณต้องสร้างสิ่งนี้เพื่อส่งผ่านอาร์กิวเมนต์ในตัวสร้าง ViewModel ของคุณ

การฉีดพึ่งพา

ฉันจะอธิบายการฉีดพึ่งพาด้วยตัวอย่าง หากคุณดูที่คลาส PostDataRepository ของเรา มีการอ้างอิงสองรายการคือ LocalDataSource และ RemoteDataSource เราใช้คลาสการ Injection เพื่อจัดเตรียมการพึ่งพาเหล่านี้ให้กับคลาส PostDataRepository

การพึ่งพาการฉีดมีข้อดีสองประการหลัก หนึ่งคือคุณจะต้องควบคุมการสร้างอินสแตนซ์ของอ็อบเจ็กต์จากจุดศูนย์กลาง แทนที่จะกระจายไปทั่วทั้งโค้ดเบส อีกประการหนึ่งคือสิ่งนี้จะช่วยให้เราเขียนการทดสอบหน่วยสำหรับ PostDataRepository เพราะตอนนี้เราสามารถส่ง LocalDataSource และ RemoteDataSource ไปยัง PostDataRepository constructor แทนค่าจริงได้

 object Injection { fun providePostDataRepository(): PostDataRepository { return PostDataRepository.getInstance(provideLocalDataSource(), provideRemoteDataSource()) } fun provideViewModelFactory() = ViewModelFactory.getInstance() fun provideLocalDataSource(): PostDataSource = LocalDataSource.getInstance() fun provideRemoteDataSource(): PostDataSource = RemoteDataSource.getInstance() fun provideGetPosts() = GetPosts(providePostDataRepository()) fun provideSavePost() = SavePost(providePostDataRepository()) fun provideUseCaseHandler() = UseCaseHandler.getInstance() }

หมายเหตุ: ฉันชอบใช้ Dagger 2 สำหรับการฉีดพึ่งพาในโครงการที่ซับซ้อน แต่ด้วยช่วงการเรียนรู้ที่สูงชัน มันอยู่นอกเหนือขอบเขตของบทความนี้ ดังนั้นหากคุณสนใจที่จะลงลึกกว่านี้ ฉันขอแนะนำการแนะนำของ Hari Vignesh Jayapalan เกี่ยวกับ Dagger 2

MVVM พร้อมสถาปัตยกรรมที่สะอาด: การผสมผสานที่ลงตัว

จุดประสงค์ของเราในโครงการนี้คือเพื่อทำความเข้าใจ MVVM ด้วย Clean Architecture ดังนั้นเราจึงข้ามบางสิ่งที่คุณสามารถลองปรับปรุงเพิ่มเติมได้:

  1. ใช้ LiveData หรือ RxJava เพื่อลบการโทรกลับและทำให้เรียบร้อยขึ้นเล็กน้อย
  2. ใช้สถานะเพื่อแสดง UI ของคุณ (สำหรับเรื่องนั้น ลองดูการบรรยายที่น่าทึ่งนี้โดย Jake Wharton)
  3. ใช้ Dagger 2 เพื่อฉีดการอ้างอิง

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

ที่เกี่ยวข้อง: Xamarin Forms, MVVMCross และ SkiaSharp: The Holy Trinity of Cross-Platform App Development