Computer >> 컴퓨터 >  >> 체계 >> Android

Android 앱 아키텍처를 단순화하는 방법:코드 샘플이 포함된 세부 가이드

개별 프로그래머는 다양한 작업을 수행하는 방법에 대한 아이디어와 견해를 포함하여 자신의 비전에 따라 모바일 앱을 개발합니다. 때때로 그들은 객체 지향 또는 함수형 프로그래밍의 주요 원칙을 무시할 수 있으며, 이는 개발자들 사이에서 방향 감각 상실로 이어질 수 있습니다.

이것은 좋지 않습니다. 그들은 그들의 코드를 다룰 수 없을 것입니다. 그리고 프로젝트를 유지 관리하거나 수정해야 하는 다음 개발자는 미쳐버릴 수 있습니다. 유지 관리가 복잡한 프로세스가 되기 때문에 이러한 프로젝트를 처음부터 다시 빌드하는 것이 좋습니다.

Google이 지원되는 첫 번째 아키텍처를 출시할 때까지 거의 모든 소프트웨어 개발 회사는 자체 아키텍처를 사용했습니다. 이를 통해 코드를 더 명확하게 만들고 프로젝트 간에 전환할 수 있었습니다. 하지만 개발자가 회사를 바꾸면 새 프로젝트와 함께 새 아키텍처를 배우는 데 시간이 걸릴 것입니다.

현재 Google 덕분에 Android 개발자를 위한 16가지 아키텍처가 있습니다.

  • 6개의 안정적인 샘플(자바),
  • 안정된 샘플 2개(Kotlin):
  • 4개의 외부 샘플
  • 사용되지 않는 샘플 3개,
  • 1개의 샘플이 진행 중입니다.

어떤 아키텍처를 사용하든 특정 목적, 접근 방식 및 다양한 기능 구현을 위한 다양한 툴킷의 애플리케이션에 따라 다릅니다. 그리고 그것은 프로그래밍 언어에 따라 다릅니다.

그러나 이러한 모든 아키텍처에는 네트워크, 데이터베이스, 종속성 및 콜백 처리를 위한 논리를 거의 동등하게 나누는 하나의 공통 아키텍처 기반이 있습니다.

프로세스 중에 사용된 도구

이 모든 아키텍처를 연구한 후 단순화된 접근 방식을 구축하고 더 적은 수의 레이어를 가진 아키텍처를 생각해 냈습니다. 뉴스 목록을 로드하고 즐겨찾기에 스토리를 저장한 다음 필요한 경우 내 접근 방식을 사용하여 삭제할 수 있는 간단한 Android 앱을 구현하는 방법을 보여 드리겠습니다.

Android 앱 아키텍처를 단순화하는 방법:코드 샘플이 포함된 세부 가이드

다음은 내가 사용한 기술에 대한 요약입니다.

  • 코틀린 AndroidX와 함께 앱 개발 라이브러리
  • Room SQLite 데이터베이스로
  • 스테토 기지의 데이터 탐색
  • 개조2 RxJava2와 함께 서버 요청을 기록하고 서버 응답을 받는 데 도움이 됩니다.
  • 글라이드 이미지 처리
  • Android 아키텍처 구성요소 (LiveData, ViewModel, Room) 및 ReactiveX (RxJava2, RxKotlin и RxAndroid) 의존성 구축, 동적 데이터 변경 및 비동기 처리를 위한 것입니다.

이것이 제가 프로젝트에 사용한 모바일 앱 기술 스택입니다.

시작하자

첫 번째 단계

AndroidX 연결 . gradle.properties에서 앱 수준에서 다음을 작성하세요.

android.enableJetifier=true
android.useAndroidX=true

이제 build.gradle 의 종속성을 교체해야 합니다. Android에서 AndroidX까지 앱 모듈 수준에서. ext, 에 대한 모든 종속성을 추출해야 합니다. build.gradle 의 Kotlin 기본 버전 관리의 예에서 볼 수 있듯이 앱 수준에서. 그런 다음 거기에 Gradle 버전 관리를 추가합니다.

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
    }
}

다른 모든 종속성에 대해서는 ext 여기서 SDK 버전을 포함한 모든 종속성을 절대적으로 추가하고, 버전 관리를 나누고, build.gradle 에서 추가로 구현될 종속성 대산괴를 생성합니다. 앱 수준에서. 다음과 같이 표시됩니다.

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"
    ]
}

버전 및 대산괴 이름은 무작위로 구현됩니다. 그런 다음 build.gradle 에서 종속성을 구현합니다. 다음과 같이 앱 수준에서:

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 를 지정하는 것을 잊지 마십시오. 기본 구성에서. 대부분의 경우 빠르게 사용되는 방법 수의 한계에 도달하게 됩니다.

같은 방식으로 앱의 모든 종속성을 선언해야 합니다. 앱을 인터넷에 연결할 수 있는 권한을 추가해 보겠습니다.

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

매니페스트에 이름이 추가되지 않은 경우 Stetho 이후로 이름을 추가해야 합니다. 이름 없는 앱을 볼 수 없으며 데이터베이스를 볼 수 없습니다.

기본 구성요소 구축

이 아키텍처를 구축하기 위한 기반으로 MVVM(Model-View-ViewModel) 패턴이 사용되었다는 점은 주목할 가치가 있습니다.

개발을 시작해 보겠습니다. 가장 먼저 해야 할 일은 Application()을 상속할 클래스를 만드는 것입니다. 이 수업에서는 나중에 사용할 수 있도록 앱 컨텍스트에 대한 액세스 권한을 부여합니다.

@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)
    }
}

두 번째 단계는 각 Activity 또는 Fragment에 사용할 ViewModel로 시작하는 기본 앱 구성 요소를 만드는 것입니다.

abstract class BaseViewModel constructor(app: Application) : AndroidViewModel(app) {

    override fun onCleared() {
        super.onCleared()
    }
}

이 앱에는 복잡한 기능이 없습니다. 그러나 기본 ViewModel에서는 3개의 주요 LiveData를 :

  • 오류 처리
  • 진행률 표시줄이 표시된 로드 처리
  • 그리고 목록이 있는 앱이 있으므로 어댑터의 영수증 및 데이터 가용성을 부재 시 표시되는 자리 표시자로 처리합니다.
val errorLiveData = MediatorLiveData<String>()
    val isLoadingLiveData = MediatorLiveData<Boolean>()
    val isEmptyDataPlaceholderLiveData = MediatorLiveData<Boolean>()

기능 구현 결과를 LiveData로 전송하려면 소비자를 사용합니다. .

앱의 모든 위치에서 오류를 처리하려면 Throwable.message 를 전송할 소비자를 생성해야 합니다. errorLiveData 값 .

또한 기본 VewModel에서 구현하는 동안 진행률 표시줄을 표시하기 위해 LiveData 목록을 수신하는 메서드를 만들어야 합니다.

기본 ViewModel은 다음과 같습니다.

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
        }
    }
}


우리 앱에서 두 개의 화면(뉴스 목록 화면과 즐겨찾기 목록 화면)에 대해 몇 가지 활동을 만드는 것은 이치에 맞지 않습니다. 하지만 이 샘플은 최적화되고 쉽게 확장 가능한 아키텍처의 구현을 보여 주므로 기본 앱을 만들겠습니다.

우리 앱은 1개의 Activity와 2개의 Fragments를 기반으로 구축되며 Container Activity에서 확장됩니다. 활동의 XML 파일은 다음과 같습니다.

<?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_placeholderinclude_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)
    }
}

앞으로의 모든 활동의 프로세스에서 발생할 수 있는 오류를 표시하는 방법을 구현해 보겠습니다. 저는 편의상 일반적인 토스트 형식으로 하겠습니다.

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

이 오류 텍스트를 표시 방법으로 보냅니다.

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

기본 활동에서 errorLiveData의 변경 사항을 따라가기 시작하겠습니다. 기본 뷰 모델에 있는 값입니다. startObserveLiveData() 메소드는 다음과 같이 변경됩니다:

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

이제 onErrorConsumer 를 사용하고 있습니다. 기본 ViewModel의 onError 프로세서에 구현된 메서드 오류에 대한 메시지가 표시됩니다.

활동의 프래그먼트를 백 스택에 추가할 수 있는 기능으로 대체할 수 있는 메서드를 만듭니다.

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()
        }
    }

필요한 앱 위치에 진행 상황과 자리 표시자를 표시하기 위한 인터페이스를 만들어 보겠습니다.

interface EmptyDataPlaceholderCallback {

    fun onShowPlaceholder()

    fun onHidePlaceholder()
}
interface ProgressViewCallback {

    fun onShowProgress()

    fun onHideProgress()
}

기본 활동에서 구현합니다. 진행률 표시줄과 자리 표시자에 대한 ID 설정의 기능을 만들고 이러한 보기도 초기화했습니다.

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()
    }

마지막으로 onCreate에서 View에 대한 ID를 설정하는 방법:

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

기본 ViewModel 및 Basic Activity 생성에 대해 설명했습니다. 기본 프래그먼트는 동일한 원칙에 따라 생성됩니다.

각각의 개별 화면을 생성할 때 추가 확장 및 가능한 변경을 고려하고 있다면 해당 ViewModel을 사용하여 별도의 Fragment를 생성해야 합니다.

참고:프래그먼트가 하나의 클러스터에 결합될 수 있고 비즈니스 로직이 엄청난 복잡성을 의미하지 않는 경우 여러 프래그먼트가 하나의 ViewModel을 사용할 수 있습니다.

프래그먼트 간 전환은 Activity에서 구현된 인터페이스 때문에 발생합니다. 이렇게 하려면 각 프래그먼트에 컴패니언 개체{} 가 있어야 합니다. Bundle로 인수를 전송하는 기능으로 Fragment 개체 구축 방법 사용 :

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

아키텍처 솔루션

기본 컴포넌트가 생성되면 아키텍처에 집중할 때입니다. 도식적으로 그것은 유명한 로버트 C. 마틴이나 밥 아저씨가 만든 깨끗한 건축물처럼 보일 것입니다. 하지만 RxJava2를 사용하기 때문에 , 경계를 제거했습니다. 인터페이스(종속성 규칙 실행) Observable 표준에 찬성 및 구독자 .

이 외에도 RxJava2 를 사용하여 도구 보다 유연한 작업을 위해 데이터 변환을 통합했습니다. 이는 서버 응답과 데이터베이스 작업 모두와 관련이 있습니다.

기본 모델 외에 Room에 대한 서버 응답 모델과 별도의 테이블 모델을 생성하겠습니다. . 이 두 모델 간에 데이터를 변환하면 변환 프로세스 중에 변경을 수행하고, 서버 응답을 변환하고, UI에 표시되기 전에 필요한 데이터를 베이스에 저장하는 등의 작업을 수행할 수 있습니다.

프래그먼트는 UI를 담당합니다. , ViewModel Fragments는 비즈니스 로직 실행을 담당합니다. 비즈니스 논리가 전체 활동과 관련된 경우 ViewModel 활동입니다.

ViewModel은 val ... by lazy{}, 를 통해 초기화하여 제공자로부터 데이터를 얻습니다. 불변 개체 또는 lateinit var, 가 필요한 경우 반대의 경우도 마찬가지입니다. 비즈니스 로직 실행 후 UI 변경을 위해 데이터 전송이 필요한 경우 새로운 MutableLiveData 를 만듭니다. observeLiveData() 에서 사용할 ViewModel에서 Fragment의 메소드.

아주 쉽게 들립니다. 구현도 간단합니다.
우리 아키텍처의 필수 구성 요소는 한 데이터 유형에서 다른 데이터 유형으로의 간단한 변환을 기반으로 하는 데이터 변환기입니다. RxJava 변환용 데이터 스트림, SingleTransformer 또는 FlowableTransformer 종류에 따라 사용합니다. 우리 앱의 경우 변환기의 인터페이스 및 추상 클래스는 다음과 같습니다.

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
}

이 예에서는 모델-모델, 모델 목록 - 모델 목록 및 동일한 조합과 같은 기본 변환을 사용하지만 SingleTransformer 만 사용합니다. 데이터베이스의 서버 응답 및 요청 처리를 위한 것입니다.

RestClient를 사용하여 네트워크부터 시작하겠습니다. RetrofitBuilder 방법은 다음과 같습니다.

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"

타사 API를 사용하면 항상 서버에서 절대 null 응답을 받을 기회가 있으며 여기에는 여러 가지 이유가 있을 수 있습니다. 그렇기 때문에 추가 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) }
            }
        }
    }
}

모델을 생성하려면 API를 기반으로 빌드해야 합니다. 예를 들어, newsapi.org에서 비상업적인 용도로 무료 APU를 사용할 것입니다. 요청된 기능 목록이 다소 광범위하지만 이 예제에서는 작은 부분을 사용하겠습니다. 빠른 등록 후 API 및 api 키 에 액세스할 수 있습니다. 각 요청에 필요한 것입니다.

끝점으로 https://newsapi.org/v2/everything을 사용합니다. <강하다>. 제안된 쿼리 에서 다음을 선택합니다. q - 검색어, 에서 - 날짜부터 다음으로 정렬 - 현재까지 정렬, sortBy - 선택한 기준에 따른 정렬 및 필수 apiKey

RestClient 이후 생성할 때 앱에 대해 선택한 쿼리를 사용하여 API 인터페이스를 만듭니다.

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"

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)

전체 응답의 이러한 데이터는 사진, 제목 및 뉴스 설명이 포함된 목록을 표시하기에 충분합니다.

아키텍처 접근 방식을 구현하기 위해 일반 모델을 생성해 보겠습니다.

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 모델은 어댑터에 표시되는 데이터베이스 및 데이터와의 연결에 사용되므로 목록의 UI 요소를 변경하는 데 사용할 2개의 여백을 추가해야 합니다.

요청에 대한 모든 준비가 완료되면 NetworkModule을 통해 수신되는 뉴스 쿼리에 사용할 네트워크 모델용 변환기를 만듭니다.

변환기는 중첩에서 역순으로 생성되며 그에 따라 직접 순서로 변환됩니다. 따라서 첫 번째 항목은 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) })
    }
}

위와 같이 News 객체 변환 시 Article 객체 목록의 변환도 함께 수행되는 것을 볼 수 있습니다.

네트워크 모델에 대한 변환기가 생성되면 모듈(repository network) 생성을 진행해 보겠습니다. 일반적으로 인터페이스 API는 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 키는 제안된 엔드포인트에서 요청하기 위한 중요한 매개변수입니다. 그렇기 때문에 선택적 매개변수가 미리 지정되지 않았는지 확인하고 기본적으로 무효화해야 합니다.

위에서 보듯이 응답 처리 중에 데이터 변환을 적용했습니다.

데이터베이스 작업을 해보자. 앱 데이터베이스를 만들고 AppDatabase 라고 합니다. RoomDatabase()에서 상속 .

데이터베이스 초기화를 위해서는 DatabaseCreator 생성이 필요합니다. , 에서 초기화해야 합니다. 수업.

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() })
    }
}

이제 onCreate()에서 메서드 클래스 I 초기화 Steto 및 데이터베이스:

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

데이터베이스가 생성되면 내부에 단일 insert() 메서드가 있는 기본 Dao를 생성합니다.

@Dao
interface BaseDao<in I> {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(obj: I)
}

우리 앱의 아이디어를 기반으로 내가 좋아하는 뉴스를 저장하거나 저장된 기사 목록을 가져오거나 저장된 뉴스를 ID별로 삭제하거나 테이블에서 모든 뉴스를 삭제합니다. 우리의 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()
}

뉴스 테이블은 다음과 같습니다.

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

테이블이 생성되면 데이터베이스와 연결해 보겠습니다.

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

    abstract fun newsDao(): NewsDao
}

이제 데이터베이스로 작업하고 데이터베이스에서 데이터를 저장하고 추출할 수 있습니다.

모듈(리포지토리 네트워크)의 경우 모델 변환기 - 데이터베이스 테이블 모델을 만듭니다.

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는 다양한 테이블 작업에 사용할 수 있습니다. 작성해 보겠습니다. 앱에 충분한 가장 간단한 버전에서 다음과 같이 보일 것입니다.

abstract class BaseRepository<M, DBModel> {

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

BaseRepository를 만든 후 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())
}

영구 리포지토리 및 모듈이 생성되면 요구 사항에 따라 네트워크 또는 데이터베이스에서 데이터를 요청하는 앱 공급자로부터 데이터가 흘러야 합니다. 공급자는 두 저장소를 결합해야 합니다. 다양한 모델과 리포지토리의 기능을 고려하여 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
}


그런 다음 뉴스 제공업체 다음과 같이 표시됩니다.

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 에서 추가 사용을 위해 공급자의 모든 메서드를 선언합니다.

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)
    }

ViewModel에서 비즈니스 로직을 실행하는 모든 메소드를 선언한 후 observeLiveData()에 있는 Fragment에서 다시 호출합니다. 선언된 각 LiveData 의 결과 처리됩니다.

쉽게 구현하려면 SEARCH_FOR 내가 임의로 선택한 매개변수 Apple, 추가 정렬은 인기 꼬리표. 필요한 경우 이러한 매개변수를 변경하기 위한 최소한의 기능을 추가할 수 있습니다.

newsapi.org에서는 뉴스 ID를 제공하지 않으므로 요소 인덱스를 ID로 허용합니다. 인기 태그로 정렬하는 것도 API를 통해 구현됩니다. 하지만 인기도순으로 정렬할 때 베이스에서 같은 ID로 데이터를 다시 쓰는 것을 피하기 위해 뉴스 목록을 로드하기 전에 베이스에서 데이터 가용성을 확인합니다. 베이스가 비어 있으면 새 목록이 로드되고, 그렇지 않으면 알림이 표시됩니다.

onViewCreated() 를 호출해 보겠습니다. NewsFragment 메소드 다음 방법:

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

베이스가 비어 있으므로 loadNews() 메소드 출시됩니다. observeLiveData 에서 메서드 로드 LiveData를 사용할 것입니다. - viewModel.loadNewsSuccessLiveData.observe(..){news →}, 요청이 성공하면 뉴스 기사 목록을 받은 다음 어댑터로 전송합니다.

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

앱을 실행하면 다음과 같은 결과가 표시됩니다.

Android 앱 아키텍처를 단순화하는 방법:코드 샘플이 포함된 세부 가이드

오른쪽의 도구 모음 메뉴에서 정렬 및 즐겨찾기의 2가지 옵션을 볼 수 있습니다. 인기도순으로 목록을 정렬하고 다음 결과를 얻습니다.

Android 앱 아키텍처를 단순화하는 방법:코드 샘플이 포함된 세부 가이드

즐겨찾기에 들어가면 베이스에 데이터가 없기 때문에 플레이스홀더만 보입니다. 즐겨찾기 화면은 다음과 같습니다.

Android 앱 아키텍처를 단순화하는 방법:코드 샘플이 포함된 세부 가이드

즐겨찾기의 UI 조각에는 좋아하는 뉴스 목록을 표시하는 화면과 데이터베이스 정리를 위한 도구 모음에 하나의 옵션만 있습니다. "좋아요"를 눌러 데이터를 저장하면 다음과 같은 화면이 나타납니다.

Android 앱 아키텍처를 단순화하는 방법:코드 샘플이 포함된 세부 가이드

위에서 쓴 것처럼 표준 모델에서는 일반 모델에 2개의 추가 여백이 추가되었으며 이 여백은 어댑터에 표시되는 데이터에 사용됩니다. 이제 저장된 뉴스 목록의 요소에 즐겨찾기에 추가할 수 있는 옵션이 없음을 알 수 있습니다.

var isAddedToFavorite: Boolean?
    var fragmentName: FragmentsNames?

'좋아요'를 다시 클릭하면 저장된 요소가 베이스에서 삭제됩니다.

마무리

그래서 안드로이드 앱 개발에 대한 간단하고 명확한 접근 방식을 보여 드렸습니다. 우리는 Clean Architecture의 주요 원칙을 유지하면서도 최대한 단순화했습니다.

내가 당신에게 제공한 아키텍처와 Mr. Martin의 클린 아키텍처의 차이점은 무엇입니까? 처음에 저는 제 아키텍처가 CA와 유사하다는 점에 주목했습니다. CA가 기반으로 사용되기 때문입니다. 다음은 CA 체계입니다.

Android 앱 아키텍처를 단순화하는 방법:코드 샘플이 포함된 세부 가이드

이벤트는 발표자로 이동한 다음 사용 사례로 이동합니다. 사용 사례 리포지토리를 요청합니다. 저장소는 엔티티, 생성된 데이터를 수신합니다. UseCase로 전송합니다. 따라서 사용 사례 필요한 모든 엔티티를 수신합니다. 비즈니스 로직을 구현한 후 Presenter, 그리고 결과를 UI로 전송합니다.

아래 구성표에서 컨트롤러 InputPort 에서 메소드 호출 UseCase 구현 및 OutputPort 인터페이스가 이 응답을 수신하고 발표자 구현합니다. 사용 사례 대신 발표자 에 따라 직접 계층의 인터페이스에 따라 다르며 종속성 규칙 과 모순되지 않습니다. 발표자는 이 인터페이스를 구현해야 합니다.

Android 앱 아키텍처를 단순화하는 방법:코드 샘플이 포함된 세부 가이드

따라서 외부 계층에서 구현된 프로세스는 내부 계층의 프로세스에 영향을 미치지 않습니다. 클린 아키텍처의 엔터티란 무엇입니까? 사실 특정 앱에 의존하지 않는 것이 전부이며, 많은 앱의 일반적인 개념이 될 것입니다. 그러나 모바일 개발 프로세스에서 Entity는 일반 및 상위 수준 규칙(앱 비즈니스 논리)을 포함하는 앱의 비즈니스 개체입니다.

게이트웨이는 어떻습니까? 내가 보기에 게이트웨이 데이터베이스 작업을 위한 저장소이자 네트워크 작업을 위한 모듈입니다. 처음에 Clean Architecture는 복잡한 비즈니스 앱을 구조화하기 위해 만들어졌고 데이터 변환기는 내 앱에서 그 기능을 수행하기 때문에 컨트롤러를 제거했습니다. ViewModel은 프레젠터를 대체하는 UI 처리를 위해 데이터를 프래그먼트로 전송합니다.

내 접근 방식에서는 종속성 규칙도 엄격하게 따르고 리포지토리, 모듈, 모델 및 공급자의 논리를 캡슐화하고 인터페이스를 통해 액세스할 수 있습니다. 따라서 외부 레이어의 변경 사항은 내부 레이어에 영향을 미치지 않습니다. RxJava2를 사용한 구현 프로세스 , KotlinRxKotlin LiveData 개발자의 작업을 더 쉽고 명확하게 만들고 코드를 읽기 쉽고 쉽게 확장할 수 있습니다.