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

ทำให้สถาปัตยกรรมแอป Android เรียบง่าย: คู่มือปฏิบัติพร้อมตัวอย่างโค้ด

โปรแกรมเมอร์แต่ละคนพัฒนาแอปมือถือตามวิสัยทัศน์ของตน รวมถึงความคิดและมุมมองในการทำงานต่างๆ บางครั้งอาจละเลยหลักการหลักของ object-oriented หรือ functional programming ซึ่งนำไปสู่ความสับสนในหมู่นักพัฒนา

สิ่งนี้ไม่ดี - พวกเขาจะจัดการโค้ดของตัวเองไม่ได้ และนักพัฒนาคนต่อไปที่ต้อง maintain หรือ modify โปรเจกต์อาจบ้าคลั่ง ดีกว่าจะ rebuild โปรเจกต์จากศูนย์ เพราะ maintenance กลายเป็นกระบวนการซับซ้อน

จนกว่า Google จะปล่อย architecture ที่รองรับครั้งแรก บริษัทพัฒนาซอฟต์แวร์เกือบทุกแห่งใช้ architecture ของตัวเอง ช่วยให้โค้ดชัดเจนและสลับโปรเจกต์ได้ แต่ถ้านักพัฒนาเปลี่ยนบริษัท จะใช้เวลาเรียน architecture ใหม่พร้อมโปรเจกต์ใหม่

ตอนนี้มี architectures สำหรับ Android developers 16 แบบ ขอบคุณ Google:

  • 6 stable samples (Java);
  • 2 stable samples (Kotlin):
  • 4 external samples;
  • 3 deprecated samples;
  • 1 sample in progress.

Architecture ใดก็ตามที่คุณใช้ ขึ้นอยู่กับวัตถุประสงค์เฉพาะ แนวทาง และการประยุกต์ toolkits ต่างๆ สำหรับการ implement ฟังก์ชันต่างๆ และขึ้นอยู่กับภาษาเขียนโปรแกรม

อย่างไรก็ตาม ทุก architectures นี้มี architectural foundation ร่วมกันที่แบ่ง logic สำหรับการทำงานกับ network, databases, dependencies, และการประมวลผล callbacks อย่างเกือบเท่าเทียม

เครื่องมือที่ใช้ในกระบวนการ

หลังศึกษา architectures ทั้งหมด ผมสร้างแนวทางที่เรียบง่าย และมาถึง architecture ที่มี layers น้อยกว่า จะแสดงวิธี implement แอป Android เรียบง่ายที่โหลดรายการข่าว บันทึกข่าวโปรด แล้วลบได้ ตามแนวทางของผม

ทำให้สถาปัตยกรรมแอป Android เรียบง่าย: คู่มือปฏิบัติพร้อมตัวอย่างโค้ด

สรุปเทคโนโลยีที่ใช้:

  • Kotlin พัฒนาแอปพร้อม library AndroidX
  • Room SQLite เป็น database
  • Stetho สำหรับ browse ข้อมูลใน database
  • Retrofit2 ร่วมกับ RxJava2 ช่วย log server requests และรับ server responses
  • Glide ประมวลผลรูปภาพ
  • Android Architecture Components (LiveData, ViewModel, Room) และ ReactiveX (RxJava2, RxKotlin และ RxAndroid) สำหรับสร้าง dependencies, การเปลี่ยนแปลงข้อมูลไดนามิก และประมวลผล asynchrony

นี่คือ technology stack สำหรับ mobile app ของโปรเจกต์ผม

มาเริ่มกันเลย

ขั้นตอนแรก

เชื่อมต่อ AndroidX ใน gradle.properties ระดับ app ใส่:

android.enableJetifier=true android.useAndroidX=true

ตอนนี้ต้องแทนที่ dependencies ใน build.gradle ระดับ app module จาก Android เป็น AndroidX ควร extract dependencies ทั้งหมดไป ext ตามตัวอย่าง Kotlin out-of-the-box versioning ใน build.gradle ระดับ app และเพิ่ม Gradle versioning ที่นั่น:

buildscript { ext.kotlin_version = '1.3.0' ext.gradle_version = '3.2.1' repositories { google() jcenter() maven { url 'https://jitpack.io' } mavenCentral() } dependencies { classpath "com.android.tools.build:gradle:$gradle_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } }

สำหรับ dependencies อื่นๆ ผมสร้าง ext file โดยเพิ่ม dependencies ทั้งหมดรวมถึง SDK versions แบ่ง versioning และสร้าง dependencies arrays ที่จะ implement ต่อใน build.gradle ระดับ app จะเป็นแบบนี้:

ext { compileSdkVersion = 28 minSdkVersion = 22 buildToolsVersion = '28.0.3' targetSdkVersion = 28 appcompatVersion = '1.0.2' supportVersion = '1.0.0' supportLifecycleExtensionsVersion = '2.0.0' constraintlayoutVersion = '1.1.3' multiDexVersion = "2.0.0" testJunitVersion = '4.12' testRunnerVersion = '1.1.1' testEspressoCoreVersion = '3.1.1' testDependencies = [ junit : "junit:junit:$testJunitVersion", runner : "androidx.test:runner:$testRunnerVersion", espressoCore: "androidx.test.espresso:espresso-core:$testEspressoCoreVersion" ] supportDependencies = [ kotlin : "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", appCompat : "androidx.appcompat:appcompat:$appcompatVersion", recyclerView : "androidx.recyclerview:recyclerview:$supportVersion", design : "com.google.android.material:material:$supportVersion", lifecycleExtension: "androidx.lifecycle:lifecycle-extensions:$supportLifecycleExtensionsVersion", constraintlayout : "androidx.constraintlayout:constraintlayout:$constraintlayoutVersion", multiDex : "androidx.multidex:multidex:$multiDexVersion" ] }

Version และ massif names implement แบบสุ่ม หลังจากนั้น implement dependencies ใน build.gradle ระดับ app ดังนี้:

apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' android { compileSdkVersion rootProject.ext.compileSdkVersion as Integer buildToolsVersion rootProject.ext.buildToolsVersion as String
dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) //Test testImplementation testDependencies.junit androidTestImplementation testDependencies.runner androidTestImplementation testDependencies.espressoCore //Support implementation supportDependencies.kotlin implementation supportDependencies.appCompat implementation supportDependencies.recyclerView implementation supportDependencies.design implementation supportDependencies.lifecycleExtension implementation supportDependencies.constraintlayout implementation supportDependencies.multiDex

อย่าลืมระบุ multiDexEnabled true ใน default configs ในส่วนใหญ่ จะถึงขีดจำกัดจำนวน methods เร็วมาก

เพิ่ม permissions เชื่อมต่ออินเทอร์เน็ต:

 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.INTERNET" />

หากไม่มี name ใน manifest ควรเพิ่ม เพราะ Stetho จะไม่เห็นแอปที่ไม่มีชื่อ และดู database ไม่ได้

สร้างคอมโพเนนต์พื้นฐาน

สังเกตว่าใช้ pattern MVVM (Model-View-ViewModel) เป็นพื้นฐานสร้าง architecture นี้

เริ่มพัฒนา ขั้นแรก สร้าง class ที่สืบทอด Application() ใน class นี้จะให้การเข้าถึง app context สำหรับการใช้งานต่อไป

@SuppressWarnings("all") class App : Application() { companion object { lateinit var instance: App private set } override fun onCreate() { super.onCreate() instance = this Stetho.initializeWithDefaults(this) DatabaseCreator.createDatabase(this) } }

ขั้นตอนที่สอง สร้าง basic app components เริ่มจาก ViewModel ที่จะใช้กับแต่ละ Activity หรือ Fragment

abstract class BaseViewModel constructor(app: Application) : AndroidViewModel(app) { override fun onCleared() { super.onCleared() } }

แอปนี้ไม่มีฟังก์ชันซับซ้อน แต่ใน BaseViewModel เราจะใส่ 3 LiveData หลัก:

  • จัดการ error
  • จัดการ loading พร้อมแสดง progress bar
  • และ เนื่องจากแอปมี lists จัดการการรับข้อมูลและความพร้อมของข้อมูลใน adapter เป็น placeholder แสดงเมื่อไม่มีข้อมูล
val errorLiveData = MediatorLiveData<String>() val isLoadingLiveData = MediatorLiveData<Boolean>() val isEmptyDataPlaceholderLiveData = MediatorLiveData<Boolean>()

เพื่อส่งผลลัพธ์ของ function implementation ไป LiveData จะใช้ Consumer

เพื่อจัดการ error ที่ทุกส่วนในแอป ต้องสร้าง Consumer ที่ส่งค่า Throwable.message ไป errorLiveData

ใน BaseViewModel ต้องสร้าง method ที่รับ LiveData list เพื่อแสดง progress bar ระหว่างการดำเนินการ

BaseViewModel ของเราจะเป็นแบบนี้:

abstract class BaseViewModel constructor(app: Application) : AndroidViewModel(app) { val errorLiveData = MediatorLiveData<String>() val isLoadingLiveData = MediatorLiveData<Boolean>() val isEmptyDataPlaceholderLiveData = MediatorLiveData<Boolean>() private var compositeDisposable: CompositeDisposable? = null protected open val onErrorConsumer = Consumer<Throwable> { errorLiveData.value = it.message } fun setLoadingLiveData(vararg mutableLiveData: MutableLiveData<*>) { mutableLiveData.forEach { liveData -> isLoadingLiveData.apply { this.removeSource(liveData) this.addSource(liveData) { this.value = false } } } } override fun onCleared() { isLoadingLiveData.value = false isEmptyDataPlaceholderLiveData.value = false clearSubscription() super.onCleared() } private fun clearSubscription() { compositeDisposable?.apply { if (!isDisposed) dispose() compositeDisposable = null } } }


ในแอปนี้ ไม่คุ้มค่าสร้างหลาย Activities สำหรับ 2 หน้าจอ (หน้ารายการข่าวและรายการโปรด) แต่ตัวอย่างนี้แสดงการ implement architecture ที่ optimal และขยายได้ง่าย จึงสร้าง base app

แอปจะสร้างบน 1 Activity และ 2 Fragments ที่จะ inflate ใน Container activity XML ของ Activity จะเป็น:

<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="https://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <FrameLayout android:id="@+id/flContainer" android:layout_width="match_parent" android:layout_height="match_parent" /> <include layout="@layout/include_placeholder"/> <include layout="@layout/include_progress_bar" /> </FrameLayout>

โดย include_placeholder และ include_progressbar จะเป็น:

<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="https://schemas.android.com/apk/res/android" android:id="@+id/flProgress" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/bg_black_40"> <ProgressBar android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:background="@color/transparent" /> </FrameLayout>
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="https://schemas.android.com/apk/res/android" android:id="@+id/flPlaceholder" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/bg_transparent"> <ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:background="@color/transparent" android:src="@drawable/ic_business_light_blue_800_24dp" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:layout_marginTop="40dp" android:text="@string/empty_data" android:textColor="@color/colorPrimary" android:textStyle="bold" /> </FrameLayout>

BaseActivity ของเราจะเป็นแบบนี้:

abstract class BaseActivity<T : BaseViewModel> : AppCompatActivity(), BackPressedCallback, ProgressViewCallback, EmptyDataPlaceholderCallback { protected abstract val viewModelClass: Class<T> protected abstract val layoutId: Int protected abstract val containerId: Int protected open val viewModel: T by lazy(LazyThreadSafetyMode.NONE) { ViewModelProviders.of(this).get(viewModelClass) } protected abstract fun observeLiveData(viewModel: T) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(layoutId) startObserveLiveData() } private fun startObserveLiveData() { observeLiveData(viewModel) } }

มาทำการแสดง error ที่อาจเกิดขึ้นใน processes ของ Activities ทั้งหมด จะทำแบบ Toast ธรรมดาเพื่อความเรียบง่าย

protected open fun processError(error: String) = Toast.makeText(this, error, Toast.LENGTH_SHORT).show()

และส่งข้อความ error ไป method แสดง:

protected open val errorObserver = Observer<String> { it?.let { processError(it) } }

ใน BaseActivity จะเริ่ม observe การเปลี่ยนแปลงค่า errorLiveData ใน Base ViewModel method startObserveLiveData() จะเปลี่ยนเป็น:

private fun startObserveLiveData() { observeLiveData(viewModel) with(viewModel) { errorLiveData.observe(this@BaseActivity, errorObserver) } }

ตอนนี้ใช้ onErrorConsumer ของ BaseViewModel เป็น onError processor จะเห็นข้อความ error ของ method ที่ implement

สร้าง method แทนที่ Fragments ใน Activity พร้อมเพิ่มลง Back Stack

protected open fun replaceFragment(fragment: Fragment, needToAddToBackStack: Boolean = true) { val name = fragment.javaClass.simpleName with(supportFragmentManager.beginTransaction()) { replace(containerId, fragment, name) if (needToAddToBackStack) { addToBackStack(name) } commit() } }

สร้าง interfaces สำหรับแสดง progress และ placeholder ที่จุดที่ต้องการในแอป

interface EmptyDataPlaceholderCallback { fun onShowPlaceholder() fun onHidePlaceholder() }
interface ProgressViewCallback { fun onShowProgress() fun onHideProgress() }

Implement ใน BaseActivity สร้าง functions ตั้ง ID ให้ progress bar และ placeholder และ initialize Views นี้

protected open fun hasProgressBar(): Boolean = false protected abstract fun progressBarId(): Int protected abstract fun placeholderId(): Int private var vProgress: View? = null private var vPlaceholder: View? = null
override fun onShowProgress() { vProgress?.visibility = View.VISIBLE } override fun onHideProgress() { vProgress?.visibility = View.GONE } override fun onShowPlaceholder() { vPlaceholder?.visibility = View.VISIBLE } override fun onHidePlaceholder() { vPlaceholder?.visibility = View.INVISIBLE } public override fun onStop() { super.onStop() onHideProgress() }

และใน method onCreate ตั้ง ID ให้ View:

if (hasProgressBar()) { vProgress = findViewById(progressBarId()) vProgress?.setOnClickListener(null) } vPlaceholder = findViewById(placeholderId()) startObserveLiveData()

ผมอธิบายการสร้าง BaseViewModel และ Basic Activity แล้ว Basic Fragment จะสร้างด้วยหลักการเดียวกัน

เมื่อสร้างแต่ละหน้าจอ หากพิจารณาการขยายต่อและการเปลี่ยนแปลงที่เป็นไปได้ ต้องสร้าง Fragment แยกพร้อม ViewModel ของมัน

หมายเหตุ: กรณี Fragments รวมเป็น cluster ได้ และ business logic ไม่ซับซ้อนมาก หลาย Fragments อาจใช้ ViewModel เดียวกันได้

การสลับ Fragments เกิดจาก interfaces ที่ implement ใน Activity แต่ละ Fragment ควรมี companion object{ } พร้อม method สร้าง Fragment object พร้อมส่ง arguments ไป Bundle:

companion object { fun newInstance() = FavoriteFragment().apply { arguments = Bundle() } }

โซลูชันสถาปัตยกรรม

เมื่อ basic components สร้างเสร็จ ถึงเวลา focus architecture แบบ schematic จะคล้าย clean architecture ของ Robert C. Martin (Uncle Bob) แต่เนื่องจากใช้ RxJava2 ผมกำจัด interfaces Boundaries (วิธี ensure Dependency Rule execution) เปลี่ยน用 Observable และ Subscriber มาตรฐาน

นอกจากนี้ 用 RxJava2 tools ผม integrate data conversion เพื่อทำงานกับข้อมูลยืดหยุ่นมากขึ้น เกี่ยวกับทั้ง server responses และ databases

นอกจาก primary model จะสร้าง server response model และ table model แยกสำหรับ Room การแปลงข้อมูลระหว่าง models ทำให้เปลี่ยนแปลงระหว่าง conversion ได้ แปลง server responses และบันทึกข้อมูลที่ต้องการลง base ก่อนแสดง UI ฯลฯ

Fragments รับผิดชอบ UI ViewModel Fragments รับผิดชอบ business logic ถ้า business logic เกี่ยวกับทั้ง activity ใช้ ViewModel Activity

ViewModels ได้ข้อมูลจาก provider โดย initialization ผ่าน val … by lazy{}, หากต้องการ object ไม่เปลี่ยนแปลง หรือ lateinit var, หาก相反 หลัง business logic execute หากต้องส่งข้อมูลเปลี่ยน UI สร้าง MutableLiveData ใหม่ใน ViewModel ใช้ใน method observeLiveData() ของ Fragment

聽起來ง่าย Implementation ก็ตรงไปตรงมา
คอมโพเนนต์สำคัญคือ data converter แบบ simple conversion จาก data type หนึ่งไปอีก type สำหรับ conversion RxJava data stream ใช้ SingleTransformer หรือ FlowableTransformer ขึ้นกับ type ในแอปของเรา interface และ abstract class ของ converter จะเป็น:

interface BaseDataConverter<IN, OUT> { fun convertInToOut(inObject: IN): OUT fun convertOutToIn(outObject: OUT): IN fun convertListInToOut(inObjects: List<IN>?): List<OUT>? fun convertListOutToIn(outObjects: List<OUT>?): List<IN>? fun convertOUTtoINSingleTransformer(): SingleTransformer<IN?, OUT> fun convertListINtoOUTSingleTransformer(): SingleTransformer<List<OUT>, List<IN>> } abstract class BaseDataConverterImpl<IN, OUT> : BaseDataConverter<IN, OUT> { override fun convertInToOut(inObject: IN): OUT = processConvertInToOut(inObject) override fun convertOutToIn(outObject: OUT): IN = processConvertOutToIn(outObject) override fun convertListInToOut(inObjects: List<IN>?): List<OUT> = inObjects?.map { convertInToOut(it) } ?: listOf() override fun convertListOutToIn(outObjects: List<OUT>?): List<IN> = outObjects?.map { convertOutToIn(it) } ?: listOf() override fun convertOUTtoINSingleTransformer() = SingleTransformer<IN?, OUT> { it.map { convertInToOut(it) } } override fun convertListINtoOUTSingleTransformer() = SingleTransformer<List<OUT>, List<IN>> { it.map { convertListOutToIn(it) } } protected abstract fun processConvertInToOut(inObject: IN): OUT protected abstract fun processConvertOutToIn(outObject: OUT): IN }

ในตัวอย่างนี้ ใช้ basic conversions เช่น model-model, list of models - list of models และ combination เดียวกัน แต่用 SingleTransformer สำหรับประมวลผล server responses และ requests ใน database

เริ่มจาก network - RestClient. retrofitBuilder method จะเป็น:

fun retrofitBuilder(): Retrofit = Retrofit.Builder() .baseUrl(BASE_URL) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(NullOrEmptyConverterFactory().converterFactory()) .addConverterFactory(GsonConverterFactory.create(createGsonBuilder())) .client(createHttpClient()) .build()
//base url const val BASE_URL = "https://newsapi.org"

ใช้ third-party APIs มีโอกาสได้ absolute null response จาก server เหตุผลเยอะ นั่นคือทำไมต้องมี NullOrEmptyConverterFactory เพิ่มเติมจัดการสถานการณ์นี้ โค้ดเป็นแบบนี้:

class NullOrEmptyConverterFactory : Converter.Factory() { fun converterFactory() = this override fun responseBodyConverter(type: Type?, annotations: Array<Annotation>, retrofit: Retrofit): Converter<ResponseBody, Any>? { return Converter { responseBody -> if (responseBody.contentLength() == 0L) { null } else { type?.let { retrofit.nextResponseBodyConverter<Any>(this, it, annotations)?.convert(responseBody) } } } } }

สร้าง models ต้องอาศัย API ตัวอย่างใช้ free API สำหรับ non-commercial จาก newsapi.org มี functionality เยอะ แต่ใช้บางส่วนสำหรับตัวอย่าง หลัง register เร็ว ได้ API access และ api key จำเป็นทุก request

Endpoint ใช้ https://newsapi.org/v2/everything จาก query ที่แนะนำ เลือก: q - search query, from - เรียงจากวันที่, to - เรียงถึงวันที่, sortBy - เรียงตามเกณฑ์ และ must-have apiKey

หลัง RestClient สร้าง สร้าง API interface พร้อม Query ที่เลือกสำหรับแอป:

interface NewsApi { @GET(ENDPOINT_EVERYTHING) fun getNews(@Query("q") searchFor: String?, @Query("from") fromDate: String?, @Query("to") toDate: String?, @Query("sortBy") sortBy: String?, @Query("apiKey") apiKey: String?): Single<NewsNetworkModel> }
//endpoints const val ENDPOINT_EVERYTHING = "/v2/everything"

จะได้ response ใน NewsNetworkModel:

data class NewsNetworkModel(@SerializedName("articles") var articles: List<ArticlesNetworkModel>? = listOf())
data class ArticlesNetworkModel(@SerializedName("title") var title: String? = null, @SerializedName("description") var description: String? = null, @SerializedName("urlToImage") var urlToImage: String? = null)

ข้อมูลจาก response ทั้งหมดพอสำหรับแสดง list พร้อมรูป title และคำอธิบายข่าว

สำหรับ architectural approach สร้าง general models:

interface News { var articles: List<Article>? } class NewsModel(override var articles: List<Article>? = null) : News
interface Article { var id: Long? var title: String? var description: String? var urlToImage: String? var isAddedToFavorite: Boolean? var fragmentName: FragmentsNames? } class ArticleModel(override var id: Long? = null, override var title: String? = null, override var description: String? = null, override var urlToImage: String? = null, override var isAddedToFavorite: Boolean? = null, override var fragmentName: FragmentsNames? = null) : Article

เนื่องจาก Article model ใช้เชื่อม database และแสดงข้อมูลใน adapter ต้องเพิ่ม 2 margins สำหรับเปลี่ยน UI elements ใน list

เมื่อทุกอย่างพร้อมสำหรับ request สร้าง converters สำหรับ network models ใช้ใน query รับข่าวผ่าน NetworkModule

Converters สร้างจาก nesting เรียงลำดับย้อนกลับ และ convert เรียงลำดับไปข้างหน้า ดังนั้น สร้าง Article ก่อน ตามด้วย News:

interface ArticlesBeanConverter class ArticlesBeanDataConverterImpl : BaseDataConverterImpl<ArticlesNetworkModel, Article>(), ArticlesBeanConverter { override fun processConvertInToOut(inObject: ArticlesNetworkModel): Article = inObject.run { ArticleModel(null, title, description, urlToImage, false, FragmentsNames.NEWS) } override fun processConvertOutToIn(outObject: Article): ArticlesNetworkModel = outObject.run { ArticlesNetworkModel(title, description, urlToImage) } }
interface NewsBeanConverter class NewsBeanDataConverterImpl : BaseDataConverterImpl<NewsNetworkModel, News>(), NewsBeanConverter { private val articlesConverter by lazy { ArticlesBeanDataConverterImpl() } override fun processConvertInToOut(inObject: NewsNetworkModel): News = inObject.run { NewsModel(articles?.let { articlesConverter.convertListInToOut(it) }) } override fun processConvertOutToIn(outObject: News): NewsNetworkModel = outObject.run { NewsNetworkModel(articles?.let { articlesConverter.convertListOutToIn(it) }) } }

เห็นได้ว่า ระหว่าง conversion News object ยัง execute conversion ของ Article objects list ด้วย

เมื่อ converters สำหรับ network models สร้างเสร็จ ไปสร้าง module (repository network) เนื่องจากมักมี API interfaces มากกว่า 1-2 ต้องสร้าง BaseModule, typed API, Network Module, และ ConversionModel

จะเป็นแบบนี้:

abstract class BaseNetworkModule<A, NM, M>(val api: A, val dataConverter: BaseDataConverter<NM, M>)

ตามนั้น NewsModule:

interface NewsModule { fun getNews(fromDate: String? = null, toDate: String? = null, sortBy: String? = null): Single<News> } class NewsModuleImpl(api: NewsApi) : BaseNetworkModule<NewsApi, NewsNetworkModel, News>(api, NewsBeanDataConverterImpl()), NewsModule { override fun getNews(fromDate: String?, toDate: String?, sortBy: String?): Single<News> = api.getNews(searchFor = SEARCH_FOR, fromDate = fromDate, toDate = toDate, sortBy = sortBy, apiKey = API_KEY) .compose(dataConverter.convertOUTtoINSingleTransformer()) .onErrorResumeNext(NetworkErrorUtils.rxParseError()) }

สำหรับ API นี้ api key เป็นพารามิเตอร์สำคัญ ต้องแน่ใจว่า optional parameters ไม่ได้ระบุล่วงหน้า และ nullify โดย default

เห็นได้ว่า applied data conversion ระหว่าง response processing

ไปทำงานกับ database สร้าง app database เรียก AppDatabase สืบทอด RoomDatabase()

สำหรับ database initialization สร้าง DatabaseCreator initialize ใน App class

object DatabaseCreator { lateinit var database: AppDatabase private val isDatabaseCreated = MutableLiveData<Boolean>() private val mInitializing = AtomicBoolean(true) @SuppressWarnings("CheckResult") fun createDatabase(context: Context) { if (mInitializing.compareAndSet(true, false).not()) return isDatabaseCreated.value = false Completable.fromAction { database = Room.databaseBuilder(context, AppDatabase::class.java, DB_NAME).build() } .compose { completableToMain(it) } .subscribe({ isDatabaseCreated.value = true }, { it.printStackTrace() }) } }

ตอนนี้ใน method onCreate() ของ App class initialize Stetho และ database:

override fun onCreate() { super.onCreate() instance = this Stetho.initializeWithDefaults(this) DatabaseCreator.createDatabase(this) }

เมื่อ database สร้างเสร็จ สร้าง basic Dao พร้อม method insert() เดียว:

@Dao interface BaseDao<in I> { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(obj: I) }

ตามแนวคิดแอป จะบันทึกข่าวที่ชอบ ดึงรายการ saved articles ลบข่าว saved ตาม ID หรือลบทั้งหมดจาก table NewsDao จะเป็น:

@Dao interface NewsDao : BaseDao<NewsDatabase> { @Query("SELECT * FROM $NEWS_TABLE") fun getNews(): Single<List<NewsDatabase>> @Query("DELETE FROM $NEWS_TABLE WHERE id = :id") fun deleteNewsById(id: Long) @Query("DELETE FROM $NEWS_TABLE") fun deleteFavoriteNews() }

News table จะเป็น:

@Entity(tableName = NEWS_TABLE) data class NewsDatabase(@PrimaryKey var id: Long?, var title: String?, var description: String?, var urlToImage: String?)

link table กับ database:

@Database(entities = [NewsDatabase::class], version = DB_VERSION) abstract class AppDatabase : RoomDatabase() { abstract fun newsDao(): NewsDao }

ตอนนี้ทำงานกับ database ได้ บันทึกและดึงข้อมูล

สำหรับ module (repository network) สร้าง model converter - database table model:

interface NewsDatabaseConverter class NewsDatabaseDataConverterImpl : BaseDataConverterImpl<Article, NewsDatabase>(), NewsDatabaseConverter { override fun processConvertInToOut(inObject: Article): NewsDatabase = inObject.run { NewsDatabase(id, title, description, urlToImage) } override fun processConvertOutToIn(outObject: NewsDatabase): Article = outObject.run { ArticleModel(id, title, description, urlToImage, true, FragmentsNames.FAVORITES) } }

BaseRepository สำหรับทำงานกับ tables ต่างๆ เขียนแบบ simple พอสำหรับแอป:

abstract class BaseRepository<M, DBModel> { protected abstract val dataConverter: BaseDataConverter<M, DBModel> protected abstract val dao: BaseDao<DBModel> }

สร้าง NewsRepository:

interface NewsRepository { fun saveNew(article: Article): Single<Article> fun getSavedNews(): Single<List<Article>> fun deleteNewsById(id: Long): Single<Unit> fun deleteAll(): Single<Unit> } object NewsRepositoryImpl : BaseRepository<Article, NewsDatabase>(), NewsRepository { override val dataConverter by lazy { NewsDatabaseDataConverterImpl() } override val dao by lazy { DatabaseCreator.database.newsDao() } override fun saveNew(article: Article): Single<Article> = Single.just(article) .map { dao.insert(dataConverter.convertInToOut(it)) } .map { article } override fun getSavedNews(): Single<List<Article>> = dao.getNews().compose(dataConverter.convertListINtoOUTSingleTransformer()) override fun deleteNewsById(id: Long): Single<Unit> = Single.just(dao.deleteNewsById(id)) override fun deleteAll(): Single<Unit> = Single.just(dao.deleteFavoriteNews()) }

เมื่อ repositories และ modules ถาวรสร้างเสร็จ ข้อมูลควรไหลจาก app provider ที่ request data จาก network หรือ database ขึ้นกับความต้องการ Provider ควบรวม repositories ทั้งสอง ดู capabilities ของ models และ repositories ต่างๆ สร้าง BaseProvider:

abstract class BaseProvider<NM, DBR> { val repository: DBR = this.initRepository() val networkModule: NM = this.initNetworkModule() protected abstract fun initRepository(): DBR protected abstract fun initNetworkModule(): NM }


แล้ว NewsProvider จะเป็น:

interface NewsProvider { fun loadNewsFromServer(fromDate: String? = null, toDate: String? = null, sortBy: String? = null): Single<News> fun saveNewToDB(article: Article): Single<Article> fun getSavedNewsFromDB(): Single<List<Article>> fun deleteNewsByIdFromDB(id: Long): Single<Unit> fun deleteNewsFromDB(): Single<Unit> } object NewsProviderImpl : BaseProvider<NewsModule, NewsRepositoryImpl>(), NewsProvider { override fun initRepository() = NewsRepositoryImpl override fun initNetworkModule() = NewsModuleImpl(RestClient.retrofitBuilder().create(NewsApi::class.java)) override fun loadNewsFromServer(fromDate: String?, toDate: String?, sortBy: String?) = networkModule.getNews(fromDate, toDate, sortBy) override fun saveNewToDB(article: Article) = repository.saveNew(article) override fun getSavedNewsFromDB() = repository.getSavedNews() override fun deleteNewsByIdFromDB(id: Long) = repository.deleteNewsById(id) override fun deleteNewsFromDB() = repository.deleteAll() }

ตอนนี้ดึงรายการข่าวได้ง่าย ใน NewsViewModel ประกาศ methods ทั้งหมดของ provider สำหรับใช้ต่อ:

val loadNewsSuccessLiveData = MutableLiveData<News>() val loadLikedNewsSuccessLiveData = MutableLiveData<List<Article>>() val deleteLikedNewsSuccessLiveData = MutableLiveData<Boolean>() private val loadNewsSuccessConsumer = Consumer<News> { loadNewsSuccessLiveData.value = it } private val loadLikedNewsSuccessConsumer = Consumer<List<Article>> { loadLikedNewsSuccessLiveData.value = it } private val deleteLikedNewsSuccessConsumer = Consumer<Unit> { deleteLikedNewsSuccessLiveData.value = true } private val dataProvider by lazy { NewsProviderImpl } init { ... isLoadingLiveData.apply { addSource(loadNewsSuccessLiveData) { value = false } }
@SuppressLint("CheckResult") fun loadNews(fromDate: String? = null, toDate: String? = null, sortBy: String? = null) { isLoadingLiveData.value = true isEmptyDataPlaceholderLiveData.value = false dataProvider.loadNewsFromServer(fromDate, toDate, sortBy) .compose(RxUtils.ioToMainTransformer()) .subscribe(loadNewsSuccessConsumer, onErrorConsumer) } @SuppressLint("CheckResult") fun saveLikedNew(article: Article) { Single.fromCallable { Unit } .flatMap { dataProvider.saveNewToDB(article) } .compose(RxUtils.ioToMainTransformerSingle()) .subscribe({}, { onErrorConsumer }) } @SuppressLint("CheckResult") fun removeLikedNew(id: Long) { Single.fromCallable { Unit } .flatMap { dataProvider.deleteNewsByIdFromDB(id) } .compose(RxUtils.ioToMainTransformerSingle()) .subscribe({}, { onErrorConsumer }) } @SuppressLint("CheckResult") fun loadLikedNews() { Single.fromCallable { Unit } .flatMap { dataProvider.getSavedNewsFromDB() } .compose(RxUtils.ioToMainTransformerSingle()) .subscribe(loadLikedNewsSuccessConsumer, onErrorConsumer) } @SuppressLint("CheckResult") fun removeLikedNews() { Single.fromCallable { Unit } .flatMap { dataProvider.deleteNewsFromDB() } .compose(RxUtils.ioToMainTransformerSingle()) .subscribe(deleteLikedNewsSuccessConsumer, onErrorConsumer) }

หลังประกาศ methods ทั้งหมดที่ execute business logic ใน ViewModel จะเรียกกลับจาก Fragment โดยใน observeLiveData() จะ process ผลลัพธ์ของแต่ละ LiveData ที่ประกาศ

เพื่อ implement ง่าย ในพารามิเตอร์ SEARCH_FOR เลือก Apple แบบสุ่ม และ sorting ด้วย tag popularity หากจำเป็น เพิ่ม functionality เปลี่ยน parameters ได้

เนื่องจาก newsapi.org ไม่ให้ news ID ผมรับ element index เป็น ID Sorting by popularity tag implement ผ่าน API แต่เพื่อหลีกเลี่ยง data rewriting ด้วย IDs เดียวกันใน base ระหว่าง sorting by popularity ผม verify data availability ใน base ก่อนโหลด news list ถ้า base ว่าง - โหลด list ใหม่ ถ้าไม่ - แสดง notification

เรียกใน method onViewCreated() ของ NewsFragment method:

private fun loadLikedNews() { viewModel.loadLikedNews() }

เนื่องจาก base ว่าง method loadNews() จะ launch ใน observeLiveData method ใช้ loading LiveData - viewModel.loadNewsSuccessLiveData.observe(..){news →}, รับรายการข่าวถ้า request สำเร็จ ส่งไป adapter:

isEmptyDataPlaceholderLiveData.value = news.articles?.isEmpty() with(newsAdapter) { news.articles?.toMutableList()?.let { clear() addAll(it) } notifyDataSetChanged() } loadNewsSuccessLiveData.value = null

launch แล้วจะเห็นผล:

ทำให้สถาปัตยกรรมแอป Android เรียบง่าย: คู่มือปฏิบัติพร้อมตัวอย่างโค้ด

ใน toolbar menu ขวา มี 2 options - sorting และ favorites ลอง sort ตาม popularity ได้ผล:

ทำให้สถาปัตยกรรมแอป Android เรียบง่าย: คู่มือปฏิบัติพร้อมตัวอย่างโค้ด

ไป Favorites จะเห็น Placeholder อย่างเดียว เพราะยังไม่มีข้อมูลใน base หน้าจอ Favorites จะเป็น:

ทำให้สถาปัตยกรรมแอป Android เรียบง่าย: คู่มือปฏิบัติพร้อมตัวอย่างโค้ด

UI fragment ของ Favorites มีหน้าจอแสดงรายการข่าวโปรด และ option เดียวใน toolbar สำหรับล้าง database เมื่อ save data กด "Like" หน้าจอจะเป็น:

ทำให้สถาปัตยกรรมแอป Android เรียบง่าย: คู่มือปฏิบัติพร้อมตัวอย่างโค้ด

เหมือนที่เขียน ไป 2 margins เพิ่มใน general model ใช้สำหรับแสดงข้อมูลใน adapter ตอนนี้เห็นว่า elements ของ saved news list ไม่มี option เพิ่ม favorites

var isAddedToFavorite: Boolean? var fragmentName: FragmentsNames?

กด "Like" อีกครั้ง element ที่บันทึกจะถูกลบจาก base

สรุป

ดังนั้น ผมแสดงแนวทาง Android app development ที่เรียบง่ายและชัดเจน รักษาหลักการ Clean Architecture แต่ลดความซับซ้อนให้น้อยที่สุด

ความแตกต่างระหว่าง architecture ที่ผมเสนอ และ Clean Architecture ของ Mr. Martin? ตอนต้นบอกว่า architecture ของผมคล้าย CA เพราะใช้เป็น basis นี่คือ CA scheme:

ทำให้สถาปัตยกรรมแอป Android เรียบง่าย: คู่มือปฏิบัติพร้อมตัวอย่างโค้ด

Event ไป Presenter แล้วไป Use Case. Use Case request Repository. Repository รับข้อมูล สร้าง Entity ส่งไป UseCase. ดังนั้น Use Case ได้ Entities ที่จำเป็นทั้งหมด หลัง implement business logic ได้ result กลับไป Presenter ส่งไป UI

ใน scheme ด้านล่าง Controller เรียก methods จาก InputPort ที่ implement UseCase และ OutputPort interface รับ response และ Presenter implement แทนที่ UseCase upends on Presenter มัน depend on interface ใน layers ไม่ขัดกับ Dependency Rule Presenter ควร implement interface นี้

ทำให้สถาปัตยกรรมแอป Android เรียบง่าย: คู่มือปฏิบัติพร้อมตัวอย่างโค้ด

ดังนั้น processes ใน external layer ไม่ส่งผลต่อ internal layer Entity ใน Clean Architecture คืออะไร? จริงๆ คือทุกอย่างที่ไม่ depend on specific app และเป็น general concept สำหรับหลาย apps แต่ใน mobile development Entity คือ business objects ของ app ที่มี general และ high-level rules (app business logic)

Gateways? ตามผมเห็น Gateways คือ repository ทำงานกับ database และ module ทำงานกับ network เรากำจัด controller เพราะ Clean Architecture สร้างสำหรับ business apps ความซับซ้อนสูง และ data converters ทำหน้าที่แทนในแอปของผม ViewModels ส่งข้อมูลไป Fragments สำหรับ UI processing แทน Presenters

ในแนวทางของผม ยังคง Dependency Rule อย่างเคร่งครัด logic ของ repositories, modules, models, และ providers ถูก encapsulated การเข้าถึงผ่าน interfaces การเปลี่ยนแปลง external layers ไม่ส่งผล internal layers และ implementation 用 RxJava2, KotlinRx, และ Kotlin LiveData ทำให้งานนักพัฒนาง่ายขึ้น ชัดเจน โค้ดอ่านง่าย และขยายได้ง่าย