Iniziare con Clean Architecture per Android [Parte 2]

Mar 01 2023
Questo articolo è stato precedentemente pubblicato su cobeisfresh.com.

Questo articolo è stato precedentemente pubblicato su cobeisfresh.com .

Bentornato e spero che tu sia pronto per altre righe di codice. L'ultima volta abbiamo parlato delle basi dell'architettura pulita e di come può aiutarti a separare il dominio e la logica di presentazione. Ora è il momento di diventare più avanzati.

Continua a leggere e scopri come i moduli sono collegati insieme a Gradle, come Dagger Hilt fornisce le dipendenze e come implementare il livello di presentazione nel modulo dell'app.

File Gradle

Prima di entrare nel modulo dell'app che collega tutto insieme, devi prima comprendere i file Gradle degli altri moduli. Renderanno più chiara la gestione delle dipendenze e dimostreranno la dipendenza nella direzione della stabilità.

plugins {
    id 'java-library'
    id 'kotlin'
}

java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}

plugins {
    id 'java-library'
    id 'kotlin'
}

java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}

dependencies {
    implementation project(':domain')

    // Network
    implementation "com.google.code.gson:gson:$versions.gson"
    implementation "com.squareup.retrofit2:retrofit:$versions.retrofit"
    implementation "com.squareup.retrofit2:converter-gson:$versions.retrofit"
    implementation "com.squareup.okhttp3:logging-interceptor:$versions.okhttp"
}

plugins {
    id 'com.android.library'
    id 'kotlin-android'
    id 'kotlin-kapt'
}

android {...}

dependencies {
    implementation project(':domain')

    // Room
    implementation "androidx.room:room-ktx:$versions.room"
    implementation "androidx.room:room-runtime:$versions.room"
    kapt "androidx.room:room-compiler:$versions.room"

    // Core library desugaring
    coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$versions.core_library_desugaring"
}

Per implementare i comportamenti astratti del modulo domain, roompersistence e auddrecognizer devono dipendere da esso. Questo è un chiaro esempio di dipendenza nella direzione della stabilità: il codice volatile dipende e si modella attorno ai contratti fissati dal codice stabile. In questo modo, quando il codice volatile cambia, il codice stabile rimarrà inalterato. Inoltre non ha bisogno di essere ricompilato e questo ti farà risparmiare tempo di compilazione.

Il modulo dell'app

Ogni programma ha bisogno di un punto di ingresso che inizi la sua esecuzione. Uno dei modi più semplici per raggiungere questo obiettivo è nelle app della riga di comando, che spesso hanno una funzione principale che inizializza le dipendenze del programma e quindi viene eseguito, prendendo e visualizzando il testo di input e output. Le app Android faranno qualche passo avanti e forniranno un'interfaccia utente grafica che fa cose simili ma in un modo più user-friendly. Implementano i comportamenti delle funzioni principali convenzionali con meccanismi specifici di Android, come Attività, Frammenti e Servizi. Queste classi rivolte all'utente inizializzano le dipendenze, raccolgono input e visualizzano l'output con Views in molti modi diversi e attraenti.

In altre parole, il codice Android è come una funzione principale complessa. Dovrebbe fornire ogni dipendenza, reagire all'input dell'utente e rendere l'output. È anche molto incline a cambiare a causa di eventi esterni come Google che decide di deprecare un'API o una libreria che decide di rimuovere una funzionalità da cui dipendi.

Ricorda, nessuna di queste cose è sotto il nostro controllo. Accadono tutti perché una terza parte ha deciso così, e la tua unica opzione qui è adattarti. Ecco perché il codice principale, e per estensione il codice Android, è il codice più volatile in Clean Architecture. In confronto, le modifiche al codice del dominio si verificano solo se il nostro team di progetto lo desidera. Pertanto, dovresti separare completamente il tuo codice di dominio da Android. Mantenerli indipendenti ti consentirà di modificarli separatamente. Per fare ciò, devi inserire tutto il codice che riguarda Android e la gestione delle dipendenze nel modulo dell'app.

└── app
    ├── HortonApp.kt
    ├── di
    │   ├── AppModule.kt
    │   └── ViewModelModule.kt
    ├── presentation
    │   ├── coroutines
    │   │   ├── AndroidDispatcherProvider.kt
    │   │   └── DispatcherProvider.kt
    │   ├── history
    │   │   └── HistoryViewModel.kt
    │   ├── mappers
    │   │   └── RecognizedSongMapper.kt
    │   └── recognition
    │       └── RecognitionViewModel.kt
    └── ui
        ├── composables [...]
        ├── history
        │   ├── HistoryActivity.kt
        │   ├── composables [...]
        │   └── model [...]
        ├── recognition
        │   ├── RecognitionActivity.kt
        │   ├── composables [...]
        │   ├── model [...]
        │   └── recorder [...]
        └── theme [...]

  • diin cui l'iniezione di dipendenza viene eseguita con Dagger Hilt,
  • presentationin cui ViewModels mappa i dati del dominio ai dati dell'interfaccia utente e
  • uiche rende l'interfaccia utente basata su questi dati e registra l'audio. Non esamineremo questo pacchetto qui perché è solo normale Jetpack Compose e MediaRecordercodice.
  • plugins { ... }
    android { ... }
    
    dependencies {
        implementation project(":roompersistence")
        implementation project(":auddrecognizer")
        implementation project(":domain")
    
        // Room
        implementation "androidx.room:room-ktx:$versions.room"
    
        // Network
        implementation "com.google.code.gson:gson:$versions.gson"
        implementation "com.squareup.retrofit2:retrofit:$versions.retrofit"
        implementation "com.squareup.retrofit2:converter-gson:$versions.retrofit"
        implementation "com.squareup.okhttp3:logging-interceptor:$versions.okhttp"
    
        // Dependency injection
        implementation "com.google.dagger:hilt-android:$versions.dagger"
        kapt "com.google.dagger:hilt-compiler:$versions.dagger"
    
        // Android coroutines
        implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines"
    
        // Lifecycle and extensions
        implementation "androidx.lifecycle:lifecycle-runtime-ktx:$versions.lifecycle"
        implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$versions.lifecycle"
        implementation "androidx.activity:activity-ktx:$versions.activity_ktx"
    
        // Compose
        implementation "androidx.compose.ui:ui:$versions.compose"
        // (...) other Compose dependencies like Material, Coil etc.
    
        // Core library desugaring
        coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$versions.core_library_desugaring"
    }
    

In teoria, l'inserimento delle dipendenze è un dettaglio di implementazione di basso livello che è solo un modo più sofisticato di costruire e fornire le dipendenze di cui il tuo codice ha bisogno. Pertanto, dovrebbe essere facile da sostituire e non dovresti inquinare la tua base di codice con esso. Una classe non dovrebbe sapere che le sue dipendenze vengono iniettate con una particolare libreria né che sarà un singleton, per esempio. Questa non è una sua responsabilità: dovrebbe solo sapere che può ottenere le sue dipendenze dal suo costruttore. Ma mettere in pratica questa teoria può portare a una notevole quantità di codice boilerplate.

Per rendere tutti gli altri moduli indipendenti dal modo in cui vengono iniettate le loro dipendenze, dobbiamo escludere @Inject. Ciò si traduce nella scrittura del codice come segue, anche se abbiamo creato sia le classi AuddSongRecognizerche RecognizeSongUseCase.

@Provides
fun provideSongRecognizer(
    apiInterface: AuddApiInterface
): SongRecognizer {
    return AuddSongRecognizer(apiInterface)
}

@Provides
@ViewModelScoped
fun provideRecognizeSongUseCase(
    songRecognizer: SongRecognizer, 
    songSaver: SongSaver
): RecognizeSongUseCase {
    return RecognizeSongUseCase(songRecognizer, songSaver)
}

Visualizza modelli

L'ultimo pezzo del puzzle sta collegando i casi d'uso creati a ViewModels che mapperanno i loro dati ai modelli dell'interfaccia utente, che possono essere visualizzati con la tua libreria preferita. La cosa che ci interessa qui è colmare il divario tra il dominio e i livelli dell'interfaccia utente.

@HiltViewModel
class RecognitionViewModel @Inject constructor(
    private val recognizeUseCase: RecognizeSongUseCase,
    private val recorder: Recorder
) : ViewModel() {
    private val mutableState = mutableStateOf(RecognitionScreenState())
    val state: State<RecognitionScreenState> get() = mutableState

    fun startRecording() {
        viewModelScope.launch {
            mutableState.value = mutableState.value.copy(buttonEnabled = false)
            recorder.start()
            recordingCountdown()
            recognize(filePath = recorder.stop())
        }
    }

    private fun recognize(filePath: String) {
        viewModelScope.launch {
            mutableState.value = mutableState.value.copy(
                progressBarState = ProgressBarState.Indeterminate
            )

            val result = recognizeUseCase.recognize(filePath)
            if (result is RecognizeSongResult.Success) {
                mutableState.value = mutableState.value.copy(
                    statusState = StatusState.HIDDEN,
                    recognitionState = RecognitionState.Recognized(result.recognizedSong.mapToSongUiModel())
                )
            } else {
                mutableState.value = mutableState.value.copy(
                    statusState = StatusState.NOT_RECOGNIZED,
                )
            }

            mutableState.value = RecognitionScreenState(
                recognitionState = mutableState.value.recognitionState,
                statusState = mutableState.value.statusState
            )
        }
    }

    // omitted recording helpers
}

  1. Registra l'audio per recordingCountdownun periodo di tempo, determinato dasampleDuration
  2. Richiama il caso d'uso passandogli il file audio appena registrato
  3. Mappa il suo risultato allo stato dello schermo
  4. Invia questo stato a un oggetto di stato mutabile a cui l'interfaccia utente può reagire

In questo caso, il ViewModel incorpora sia il controller che i componenti del presentatore di Clean Architecture.

Conclusione

E questo è tutto! Spero che la parte 1 e la parte 2 dell'articolo ti siano piaciute e che tu abbia imparato qualcosa di nuovo. Anche se puoi usare la struttura di questo post, consiglio vivamente di leggere anche il libro. Prima di leggerlo, ho plasmato i miei progetti personali attorno alle librerie che avrei usato. Ora, prima penso al loro scopo e ai loro requisiti e poi scelgo le librerie da abbinare.

Questo è un cambio di paradigma molto importante che renderà il tuo codice più stabile, testabile e flessibile. Se potessi usarne di più nei tuoi progetti, non aspettare oltre e leggi il libro!