Введение

В этом гайде мы напишем с вами Android-приложение с нуля, используя лучшие архитектурные подходы – Clean Architecture и MVVM с элементами MVI, они обеспечат поддерживаемость, тестируемость и масштабируемость приложений, что особенно важно для сложных и долгосрочных проектов..

Стек в нашем проекте будет следующим:

  • Kotlin, Kotlin Coroutines, StateFlow – для асинхронной обработки данных и управления состоянием

  • Jetpack Compose – для создания современного пользовательского интерфейса

  • Room – для локального хранения данных

  • Dagger Hilt для dependency injection (внедрение зависимостей)

Статья рассчитана на разработчиков, которые знакомы с базовыми концепциями Android-разработки, но хотят освоить более структурированные и поддерживаемые подходы к разработке. Мы создадим приложение Just Notes, которое позволит управлять заметками: добавлять, редактировать, удалять и просматривать их на главном экране.

В процессе мы будем следовать лучшим практикам, таким как разделение на слои data, domain, и presentation, и применение принципов SOLID. Мы рассмотрим основные этапы разработки, начиная от настройки проекта и добавления зависимостей, до реализации сложных сценариев использования, включая навигацию и управление состоянием с помощью ViewModel.

Этот гайд поможет вам понять, как строить грамотную архитектуру Android-приложений. Он будет особенно полезен новичкам, но и опытные разрабы, возможно, найдут здесь что-то интересное для себя. Приступим!

Начало работы

Чтобы начать разработку, нам потребуется настроить Android Studio и создать новый проект. Если Android Studio ещё не установлена, скачайте её с официального сайта Android Studio.

Создание проекта

  • Открываем Android Studio, если у вас еще не открыт проект в студии, кликаем New Project, если же у вас уже открыта студия с проектом кликаем File New New Project.

  • В появившемся окне New Project выберите тип проекта – Empty Activity. Слева должно остаться Phone and Tablet.

  • В поле Name вводим название приложения, в нашем случае Just Notes, или можете придумать и ввести свое название.

  • Package name и Minimum SDK не трогаем

  • Language конечно Kotlin

  • Build configuration language – выбираем Kotlin DSL

Более подробный гайд с изображениями вы можете найти в официальной документации Android.

Добавление зависимостей

Для нашего проекта мы как настоящие профи будем использовать Gradle version catalogs, что позволит централизованно управлять зависимостями и облегчит их обновление.

Создание libs.versions.toml файла (файл каталога версий):

  • Проверьте, есть ли в вашем проекте файл libs.versions.toml (в gradle/). Если его нет, создайте его вручную

  • Добавьте необходимые зависимости, такие как Jetpack Compose, Room, Dagger Hilt и другие.

Взять все необходимые зависимости вы сможете по ссылке.

Настройка Dagger Hilt

Создайте класс JustNotesApplication и добавьте аннотацию @HiltAndroidApp:

@HiltAndroidApp
class JustNotesApplication : Application() { }

Зарегистрируйте его в AndroidManifest.xml:

<application
  android:name=".JustNotesApplication"
  ...
/>

Структура и архитектура проекта

Если вы еще не забыли, в проекте будет использоваться всеми любимый Clean Architecture, а это значит, что все модули в нашем проекте будем разделять по слоям  data, domain, presentation:

  • data включает имплементации Repository, DataSource и классы для работы с Room.

  • domain будет содержать UseCase и интерфейсы Repository 

  • presentation слой будет содержать Composable и ViewModel.

У нашего приложения будут следующие фичи:

  • Отображение всех заметок на главном экране (что то вроде домашней страницы)

  • Создание заметок

  • Редактирование заметок

  • Удаление заметок

Проект будет состоять из модулей:

  • core – модуль, который будет использоваться всеми другими фичами. В core будут находится имплементации Repository и DataSource-ы, классы для работы с Room, общая навигация и Composable-ы.

  • create_and_update_note – объединенные фичи для создания и обновления заметок, тут будут лежать UseCase и их имплементации, экраны Composable и ViewModel.

  • home будет отвечать за отображение всех заметок, в нем будут все те же файлы, что и в create_and_update_note.

  • navigation – будет отвечать за навигацию в приложении 

Так как в нашем проекте только один источник данных, data слой будет использоваться всеми модулями.

Модуль core

Модуль core в проекте выполняет роль основы для других модулей. Он содержит общие компоненты, которые используются всеми частями приложения, такие как работа с данными, общие модели данных, репозитории и общая навигация.

Работа с локальной базой данных Room

Теперь приступим непосредственно к написанию кода. Как я писал ранее, для работы с локальной БД мы будем использовать Room, кто не знаком с этой библиотекой, рекомендую ознакомиться с документацией.

Будем придерживаться нашей задуманной структурой проекта, поэтому сначала в корне нашего проекта создадим пакет core, далее в core создаем пакеты datasourcelocalmodel.

В model создаем дата класс NoteEntity – это будет единицей локальной БД, грубо говоря одна строчка в таблице БД:

@Entity
data class NoteEntity(
   @PrimaryKey(autoGenerate = true)
   val id: Int,
   @ColumnInfo val title: String,
   @ColumnInfo val description: String,
)

Аннотация @Entity определяет, что данный класс будет использоваться как единица данных в БД. В эту аннотацию также можно указать tableName, если его не указать, название таблицы будет дефолтным – noteEntity.

@PrimaryKey обозначает что поле id будет первичным ключом, который будет генерироваться автоматически при добавлении заметки. И последнее: title – название таблицы, а description – описание.

В local, создаем пакет dao и в нем создаем интерфейс NoteDao — это сущность, которая будет определять методы для взаимодействия с БД:

@Dao
interface NoteDao {
   @Query("SELECT * FROM noteEntity")
   fun getAllNotes() : Flow<List<NoteEntity>>
   @Insert
   fun insertNote(note: NoteEntity)
   @Update
   fun updateNote(note: NoteEntity)
   @Query("SELECT * FROM noteEntity WHERE id=:id")
   fun getNoteById(id: Int) : Flow<NoteEntity>
   @Delete
   fun delete(note: NoteEntity)
}

Методы в NoteDao позволяют выполнять основные операции CRUD (создание, чтение, обновление и удаление) с таблицей noteEntity.

Далее в local создаем пакет db и в нем абстрактный класс NotesRoomDatabase:

@Database(entities = [NoteEntity::class], version = 1)
abstract class NotesRoomDatabase: RoomDatabase() {
   abstract fun noteDao() : NoteDao
}

Этот класс будет определять БД и связывать её с NoteEntity и NoteDao.

Вот мы уже и засетапили Room, теперь нужно создать DataSource, который будет работать с ним, а перед этим в core создадим пакеты domain model и в последнем создадим дата класс Note для работы с данными за пределами Room:

data class Note(
   val id: Int,
   val title: String,
   val description: String
)

Теперь возвращаемся в пакет core/data и создаем в нем пакет mapper, в котором создадим файл NoteEntityMapper, это будет маппер NoteEntity в Note и наоборот, это поможет отделить логику хранения данных от бизнес-логики и поддержать принципы Clean Architecture:

fun NoteEntity.toNote() = Note(id, title, description)
fun Note.toNoteEntity() = NoteEntity(id, title, description)

Чтобы абстрагироваться от Room и повысить гибкость, мы создадим интерфейс LocalDataSource в пакете local:

interface LocalDataSource {

   fun getAllNotesFlow(): Flow<List<Note>>

   fun gelNoteByIdFlow(id: Int): Flow<Note>

   suspend fun addNote(note: Note)

   suspend fun updateNote(note: Note)

   suspend fun deleteNote(note: Note)
}

У LocalDataSource все функции такие же как у NoteDao, только вместо NoteEntity используется Note. Вы спросите зачем эта копипаста, если можно использовать NoteDao напрямую, а затем чтобы не зависеть от библиотеки, возможно в будущем мы захотим использовать другую либу для локальной БД и тогда нам нужно будет просто заменить Room на нее, не трогая остальной код. 

В том же пакете local создадим класс RoomLocalDataSource, который будет использовать NoteDao для взаимодействия с Room:

class RoomLocalDataSource @Inject constructor(
   private val noteDao: NoteDao
) : LocalDataSource {


   override fun getAllNotesFlow(): Flow<List<Note>> {
       return noteDao.getAllNotes().map { noteEntityList ->
           noteEntityList.map { noteEntity -> noteEntity.toNote() }
       }
   }
   
   override suspend fun addNote(note: Note) {
       noteDao.insertNote(note.toNoteEntity())
   }
   
   override suspend fun deleteNote(note: Note) {
       noteDao.delete(note.toNoteEntity())
   }
   
   override suspend fun updateNote(note: Note) {
       noteDao.updateNote(note.toNoteEntity())
   }
   
   override fun gelNoteByIdFlow(id: Int): Flow<Note> {
       return noteDao.getNoteById(id).map { noteEntity ->
           noteEntity.toNote()
       }
   }
}

В core/domain создаем пакет repository и в нем интерфейс LocalDataSourceRepository, который будет представлять собой прослойку между LocalDataSource и бизнес-логикой, позволяя управлять данными, не привязываясь к конкретной реализации источника данных:

interface LocalDataSourceRepository {
В пакете core/data создаем пакет repository и в нем создаем класс LocalDataSourceRepository:
   fun getAllNotesFlow(): Flow<List<Note>>

   fun getNoteByIdFlow(id: Int): Flow<Note>

   suspend fun addNote(note: Note)

   suspend fun updateNote(note: Note)

   suspend fun deleteNote(note: Note)
}

В пакете core/data создаем пакет repository и в нем создаем класс LocalDataSourceRepository:

class LocalDataSourceRepositoryImpl @Inject constructor(
   private val localDataSource: LocalDataSource,
) : LocalDataSourceRepository {
  
   override fun getAllNotesFlow() = localDataSource.getAllNotesFlow()
   
   override fun getNoteByIdFlow(id: Int) = localDataSource.gelNoteByIdFlow(id)
   
   override suspend fun updateNote(note: Note) = localDataSource.updateNote(note)
   
   override suspend fun addNote(note: Note) = localDataSource.addNote(note)
   
   override suspend fun deleteNote(note: Note) = localDataSource.deleteNote(note)
}

И последнее, что нам осталось это создать модуль Hilt для внедрения зависимостей. В пакете core/data/source/local создаем пакет di и в нем файл LocalSourceModule:

@Module
@InstallIn(SingletonComponent::class)
class LocalSourceModuleProvider {
  
   @Provides
   fun provideNoteDao(database: NotesRoomDatabase) = database.noteDao()
   
   @Provides
   @Singleton
   fun providesLocalDatabase(
       @ApplicationContext context: Context
   ) = Room.databaseBuilder(
       context,
       NotesRoomDatabase::class.java,
       "just_notes-database"
   ).build()
}

@Module
@InstallIn(SingletonComponent::class)
abstract class LocalSourceModuleBinder {
  
   @Binds
   abstract fun bindRoomLocalDataSource(
       roomLocalDataSource: RoomLocalDataSource
   ) : LocalDataSource

   @Binds
   abstract fun bindDefaultJustNotesRepository(
       defaultJustNotesRepository: LocalDataSourceRepositoryImpl
   ) : LocalDataSourceRepository
}

ui_kit

В core создадим пакет ui_kit он будет использоваться для хранения общих UI-компонентов, extension функций и других сущностей полезных для UI. Этот модуль позволит сосредоточить все общие элементы UI в одном месте, что упростит поддержку, переиспользование и стилизацию приложения.

Пока что в этом пакете будут только Composable UI-компоненты.

Итак мы создадим такие компоненты как:

  • BasicFilledButtonJustNotes — базовая кнопка с заполненным фоном. Ее можно будет использовать в разных частях приложения, например в качестве кнопки сохранения или обновления заметки

  • CreateNoteFloatingActionButton — fab (floating action button), кнопка для создания заметки, пока она будет находится только в одном месте – на главном экране, но вполне вероятно, что она может понадобиться где-то еще.

  • JustNotesOutlinedTextField — прозрачное текстовое поле с рамкой. Этот компонент так же может быть использован во многих частях приложения, но пока он нужен только для полей Title и Description.

  • JustNotesTopBartop bar приложения

  • NoteItem — будет представлять собой заметку

Весь код для этих элементов вы можете взять из репозитория по ссылке.

Реализация домашнего экрана. Модуль home

home будет отвечать за функциональность главного экрана, где будут отображаться все созданные юзером заметки. На главном экране юзер сможет просматривать, удалять, создавать и редактировать заметки. Модуль мы разделим на domain, presentation и di, data – нам тут не нужен, так как его достаточно в core

Создаем пакет home в корне проекта, далее мы более подробно разберем каждый слой.

domain

В domain будет содержаться бизнес-логика модуля. В home создаем пакет domain

Начнем с создания UseCase-ов (далее просто юзкейс). Нам нужны всего 2 функции, для модуля home – получение всех заметок и удаление заметки. В отдельных файлах  в пакете domain создаем интерфейсы DeleteNoteUseCase и GetAllNotesUseCase:

interface DeleteNoteUseCase {

   suspend operator fun invoke(note: Note)

}

interface GetAllNotesUseCase {

   operator fun invoke(): Flow<List<Note>>

}

Возможно вы подумаете, что это излишне для столь небольшого приложения и можно просто использовать LocalDataSourceReporitory, но мы разрабатываем приложение придерживаясь лучших архитектурных паттернов и учитываем, возможное масштабирование приложения, поэтому продолжаем использовать юзкейсы.

Далее в domain создаем пакет impl, и в нем в отдельных файлах создаем классы: DeleteNoteUseCaseImpl и GetAllNotesUseCaseImpl, которые будут реализовывать наши юзкейсы:

class DeleteNoteUseCaseImpl @Inject constructor(
   private val localDataSourceRepository: LocalDataSourceRepository
): DeleteNoteUseCase {

   override suspend operator fun invoke(note: Note) {
       localDataSourceRepository.deleteNote(note)
   }
}

class GetAllNotesUseCaseImpl @Inject constructor(
   private val localDataSourceRepository: LocalDataSourceRepository
): GetAllNotesUseCase {

   override operator fun invoke() = localDataSourceRepository.getAllNotesFlow()
}

Эти юзкейсы будут использоваться в HomeScreenViewModel, который мы создадим чуть позже.

di

В home создаем пакет di, он будет отвечать за dependency injection.

Для юзкейсов мы будем использовать аннотацию @Binds, которая связывает абстракцию с имплементацией, в отличии от @Provides, @Binds улучшает производительность и уменьшает кодогенерацию. В di создаем абстрактный класс HomeScreenModule:

@Module
@InstallIn(SingletonComponent::class)
abstract class HomeScreenModule {

   @Binds
   abstract fun bindDeleteNoteUseCase(
       deleteNoteUseCaseImpl: DeleteNoteUseCaseImpl
   ) : DeleteNoteUseCase

   @Binds
   abstract fun bindGetAllNotesUseCase(
       getAllNotesUseCaseImpl: GetAllNotesUseCaseImpl
   ) : GetAllNotesUseCase
}

presentation

Думаю вы уже догадываетесь, что в этом модуле будет содержаться UI и его логика. В home создаем пакет presentation.

Начнем с ViewModel. ViewModel действует как посредник между UI и бизнес-логикой. Он обеспечивает асинхронную загрузку данных и передачу изменений в UI, что помогает поддерживать чистый и отзывчивый интерфейс. 

В presentation создаем класс HomeScreenViewModel. Так как мы используем MVVM с элементами MVI, приступим сначала к созданию событий и состояний приложения. Событие у нас будет всего одно – это удаление заметки, а состояния 2 – экран с заметками и пустой экран. События и состояния можете создать в том же файле HomeScreenViewModel или можете создать отдельные файлы для них:

internal sealed interface HomeScreenUiEvent {
   data class OnDeleteClick(val note: Note) : HomeScreenUiEvent
}

internal sealed interface HomeScreenUiState {

   data object Empty: HomeScreenUiState
   data class Content(val notes: List<Note>): HomeScreenUiState
}

События и состояния могут быть как с данными так и без. Для OnDeleteClick нам нужно понимать какую заметку хочет удалить юзер, поэтому нам нужно передавать заметку, для состояния Content нам нужно брать коллекцию заметок, чтобы отображать их на экране.

Теперь продолжим создание HomeScreenViewModel, создаем:

  • handleEvent() – метод для обработки событий

  • notes – поле, которое будет получать коллекцию заметок

  • uiState – горячий источник состояния экрана

  • deleteNote() – метод для удаления заметки

@HiltViewModel

internal class HomeViewModel @Inject constructor(
   private val deleteNoteUseCase: DeleteNoteUseCase,
   getAllNotesUseCase: GetAllNotesUseCase
) : ViewModel() {

   fun handleEvent(event: HomeScreenUiEvent) {
       when (event) {
           is HomeScreenUiEvent.OnDeleteClick -> deleteNote(event.note)
       }
   }

   private val notes = getAllNotesUseCase()

   val uiState: StateFlow<HomeScreenUiState> = notes.map { notesList ->
       if (notesList.isNotEmpty())
           HomeScreenUiState.Content(notesList)
       else HomeScreenUiState.Empty
   }.stateIn(
       scope = viewModelScope,
       started = SharingStarted.WhileSubscribed(5000),
       initialValue = HomeScreenUiState.Empty
   )

   private fun deleteNote(note: Note) {
       viewModelScope.launch(Dispatchers.IO) {
           deleteNoteUseCase(note)
       }
   }
}

Создаем файл HomeScreen, который будет содержать UI главного экрана. В основе экрана будет Scaffold, это элемент в Composable, который позволяет указать topbar, fab и другие компоненты:

@Composable

internal fun HomeScreen(
   modifier: Modifier = Modifier,
   uiState: HomeScreenUiState,
   onCreateNoteFloatingActionButtonClick: () -> Unit,
   onDeleteNoteButtonClick: (Note) -> Unit,
   onNoteClick: (String) -> Unit
) {

   Scaffold(
       modifier = modifier.fillMaxSize(),
       topBar = {
           JustNotesTopBar(
               modifier = Modifier.shadow(4.dp),
               title = stringResource(R.string.just_notes_main_topbar_title)
           )
       },
       floatingActionButton = {
           CreateNoteFloatingActionButton {
               onCreateNoteFloatingActionButtonClick()
           }
       }
   ) { paddingValues ->

       Column(
           modifier = Modifier
               .fillMaxSize()
               .padding(paddingValues)
       ) {
           when(uiState) {

               is HomeScreenUiState.Empty -> HomeScreenEmpty()

               is HomeScreenUiState.Content -> HomeScreenContent(
                   notes = uiState.notes,
                   onDeleteNoteButtonClick = onDeleteNoteButtonClick,
                   onNoteClick = onNoteClick
               )
           }
       }
   }
}

Экран у нас есть, осталось создать HomeScreenEmpty и HomeScreenContent. Их создаем в том же файле ниже:

@Composable
private fun HomeScreenContent(
    modifier: Modifier = Modifier,
    notes: List<Note>,
    onDeleteNoteButtonClick: (Note) -> Unit,
    onNoteClick: (String) -> Unit
) {
    LazyColumn(modifier = modifier.fillMaxSize()) {
        // itemsIndexed is used for UI-tests in the future
        itemsIndexed(notes) { index, note ->
            Spacer(modifier = Modifier.height(20.dp))
            NoteItem(
                modifier = Modifier
                    .padding(horizontal = 15.dp)
                    .clickable { onNoteClick(note.id.toString()) },
                title = note.title,
                description = note.description,
                onDeleteButtonClick = { onDeleteNoteButtonClick(note) }
            )
        }
    }
}

@Composable
private fun HomeScreenEmpty(modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize()) {
        Text(
            modifier = Modifier.align(Alignment.Center),
            text = stringResource(R.string.dont_have_any_notes_banner),
            color = MaterialTheme.colorScheme.primary,
            fontSize = 27.sp,
            textAlign = TextAlign.Center,
        )
    }
}

Далее в presentation создаем файл HomeScreenRoute, который будет как-бы оберткой HomeScreen:

@Composable
internal fun HomeScreenRoute(
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = hiltViewModel(),
    navigateToCreateNoteScreen: () -> Unit,
    navigateToUpdateNote: (String) -> Unit
) {

    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    HomeScreen(
        modifier = modifier,
        uiState = uiState,
        onCreateNoteFloatingActionButtonClick = navigateToCreateNoteScreen,
        onDeleteNoteButtonClick = { viewModel.handleEvent(HomeScreenUiEvent.OnDeleteClick(it)) },
        onNoteClick = navigateToUpdateNote
    )
}

И последним элементом presentation у нас будет HomeScreenNavigation, это файл который будет отвечать за навигацию. Создаем в presentation пакет navigation и в нем файл HomeScreenNavigation:

const val HOME_SCREEN_ROUTE = "home_screen"

fun NavController.navigateToHomeScreen() = navigate(HOME_SCREEN_ROUTE) {
    popUpTo(HOME_SCREEN_ROUTE) {
        inclusive = true
    }
}

fun NavGraphBuilder.homeScreen(
    navigateToCreateNoteScreen: () -> Unit,
    navigateToUpdateNote: (String) -> Unit
) {
    composable(route = HOME_SCREEN_ROUTE) {
        HomeScreenRoute(
            navigateToCreateNoteScreen = navigateToCreateNoteScreen,
            navigateToUpdateNote = navigateToUpdateNote
        )
    }
}
  • HOME_SCREEN_ROUTE – название или ключ HomeScreenRoute, он присваивается в функции composable() с помощью аргумента route.

  • fun NavController.navigateToHomeScreen() – функция для навигации на  HomeScreenRoute, popUpTo() – не позволяет пойти назад на экране HomeScreen, например из HomeScreen на экран редактирования, если юзер туда заходил.

  • fun NavGraphBuilder.homeScreen() – это функция, которая вызывается в главном файле навигации Navigation в модуле core. Это функция, которая добавляет HomeScreenRoute в NavHost, что позволяет, навигироваться с и на HomeScreenRoute.

На этом мы заканчиваем создание модуля home и переходим к созданию модуля create_and_update_note.

Создание и редактирование заметок. Модуль create_and_update_note

Этот модуль будет отвечать за всю логику, связанную с добавлением и редактированием заметок. В корне проекта создаем пакет create_and_update_note.

domain

Начнём с создания юзкейсов, в create_and_update_note создаем пакет usecase, и в нем создаем юзкейсы для создания, редактирования и получения заметок, каждый юзкейс в отдельном файле:

interface AddNoteUseCase {
    suspend operator fun invoke(note: Note)
}

interface GetNoteByIdUseCase {
    operator fun invoke(id: Int): Flow<Note>
}

interface UpdateNoteUseCase {
    suspend operator fun invoke(note: Note)
}

Далее в usecase создаем пакет impl, и в нем создаем имплементации юзкейсов, каждый в отдельном файле:

class AddNoteUseCaseImpl @Inject constructor(
    private val localDataSourceRepository: LocalDataSourceRepository
): AddNoteUseCase {

    override suspend operator fun invoke(note: Note) {
        localDataSourceRepository.addNote(note)
    }
}

class GetNoteByIdUseCaseImpl @Inject constructor(
    private val localDataSourceRepository: LocalDataSourceRepository
): GetNoteByIdUseCase {

    override operator fun invoke(id: Int) =
        localDataSourceRepository.getNoteByIdFlow(id)
}

class UpdateNoteUseCaseImpl @Inject constructor(
    private val localDataSourceRepository: LocalDataSourceRepository
): UpdateNoteUseCase {

    override suspend operator fun invoke(note: Note) = localDataSourceRepository.updateNote(note)
}

Далее реализуем dependency injection для модуля create_and_update_note, в принципе он будет практически таким же как и в home:

@Module
@InstallIn(SingletonComponent::class)
abstract class CreateUpdateNoteDomainModule {

    @Binds
    abstract fun bindAddNoteUseCase(
        addNoteUseCaseImpl: AddNoteUseCaseImpl
    ): AddNoteUseCase

    @Binds
    abstract fun bindGetNoteByIdUseCase(
        getNoteByIdUseCaseImpl: GetNoteByIdUseCaseImpl
    ): GetNoteByIdUseCase

    @Binds
    abstract fun bindUpdateNoteUseCase(
        updateNoteUseCaseImpl: UpdateNoteUseCaseImpl
    ): UpdateNoteUseCase
}

presentation

Начнем с создания ViewModel, создаем класс с названием CreateAndUpdateNoteViewModel.

Сначала создадим события и состояния, а потом перейдем к самому классу. У нас будет 3 события:

  • OnTitleChanged – изменение заголовка заметки

  • OnDescriptionChanged – изменение описания заметки

  • OnSaveClicked – нажатие на кнопку Save, или Update

Состояние будет всего одно – Content, которое будет содержать id, заголовок и описание заметки.

internal sealed interface CreateAndUpdateNoteUiEvent {

    data class OnTitleChanged(val title: String): CreateAndUpdateNoteUiEvent
    data class OnDescriptionChanged(val description: String): CreateAndUpdateNoteUiEvent
    data object OnSaveClicked: CreateAndUpdateNoteUiEvent
}

sealed interface CreateAndUpdateNoteUiState {
    data class Content(
        val id: Int = 0,
        val title: String = "",
        val description: String = ""
    ): CreateAndUpdateNoteUiState
}

 А теперь перейдем к CreateAndUpdateNoteViewModel:

@HiltViewModel
internal class CreateAndUpdateNoteViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val updateNoteUseCase: UpdateNoteUseCase,
    private val getNoteByIdUseCase: GetNoteByIdUseCase,
    private val addNoteUseCase: AddNoteUseCase
) : ViewModel() {

    private val noteId: String? = savedStateHandle[NOTE_ID_ARG]

    init {
        if (noteId != null) loadNote(noteId = noteId.toInt())
    }

    private val _uiState = MutableStateFlow<CreateAndUpdateNoteUiState>(
        CreateAndUpdateNoteUiState.Content()
    )
    val uiState = _uiState.asStateFlow()

    fun handleEvent(event: CreateAndUpdateNoteUiEvent) {
        when (event) {
            is CreateAndUpdateNoteUiEvent.OnTitleChanged -> setTitle(event.title)
            is CreateAndUpdateNoteUiEvent.OnDescriptionChanged -> setDescription(event.description)
            is CreateAndUpdateNoteUiEvent.OnSaveClicked -> addOrUpdateNote(noteId)
        }
    }

    private fun loadNote(noteId: Int) {
        viewModelScope.launch(Dispatchers.IO) {
            val note = getNoteByIdUseCase(noteId).first()
            _uiState.update {
                CreateAndUpdateNoteUiState.Content(
                    id = noteId,
                    title = note.title,
                    description = note.description
                )
            }
        }
    }

    private fun setTitle(title: String) {
        _uiState.update {
            (it as CreateAndUpdateNoteUiState.Content).copy(title = title)
        }
    }

    private fun setDescription(description: String) {
        _uiState.update {
            (it as CreateAndUpdateNoteUiState.Content).copy(description = description)
        }
    }

    private fun addOrUpdateNote(noteId: String?) {
        viewModelScope.launch(Dispatchers.IO) {
            val state = _uiState.value as CreateAndUpdateNoteUiState.Content
            val note = Note(
                id = state.id,
                title = state.title,
                description = state.description
            )

            if (noteId != null) {
                updateNoteUseCase(note)
            } else addNoteUseCase(note)
        }
    }
}

noteIdid заметки, который передается из HomeScreen и используется, чтобы получить заметку с данным id и загрузить редактирование этой заметки, id передается с помощью Navigation Component, немного позже мы подробнее разберем как передается id.

В init { } мы проверяем получили ли мы id заметки, если получили, то загружаем редактирование заметки.

В handleEvent() мы обрабатываем все события, вызывая те или иные методы.

Перейдем к созданию экрана – CreateAndUpdateNoteScreen, но сначала создадим Composable состояния Content:

@Composable
private fun Content(
    modifier: Modifier = Modifier,
    title: String,
    description: String,
    onTitleChanged: (String) -> Unit,
    onDescriptionChanged: (String) -> Unit,
    onSaveButtonClick: () -> Unit,
    navigateToHomeScreen: () -> Unit
) {
    val context = LocalContext.current

    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        JustNotesTextField(
            modifier = Modifier
                .width(300.dp)
                .padding(top = 40.dp),
            text = title,
            placeHolderText = stringResource(id = R.string.title_text_field_placeholder),
            singleLine = true,
            onValueChange = onTitleChanged
        )

        Spacer(modifier = Modifier.height(22.dp))

        JustNotesTextField(
            modifier = Modifier
                .width(300.dp)
                .height(400.dp),
            text = description,
            placeHolderText = stringResource(id = R.string.create_note_description),
            onValueChange = onDescriptionChanged
        )

        Spacer(modifier = Modifier.height(20.dp))

        BasicFilledButtonJustNotes(
            modifier = Modifier
                .width(160.dp)
                .height(50.dp),
            text = stringResource(R.string.save_button_text),
            onClick = {
                if (title.isEmpty()) {
                    Toast.makeText(
                        context, context.getText(R.string.please_fill_out_the_title_field), Toast.LENGTH_LONG
                    ).show()
                    return@BasicFilledButtonJustNotes
                }
                onSaveButtonClick()
                navigateToHomeScreen()
            }
        )
    }
}

И CreateAndUpdateNoteScreen:

@Composable
internal fun CreateAndUpdateNoteScreen(
    modifier: Modifier = Modifier,
    uiState: CreateAndUpdateNoteUiState,
    topBarTitle: Int?,
    onSaveButtonClick: () -> Unit,
    navigateToHomeScreen: () -> Unit,
    onTitleChanged: (String) -> Unit,
    onDescriptionChanged: (String) -> Unit,
) {
    Scaffold(
        modifier = modifier
            .fillMaxSize()
            .imePadding(),
        topBar = { JustNotesTopBar(title = stringResource(topBarTitle!!)) }
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues),
            contentAlignment = Alignment.Center
        ) {
            when(uiState) {
                is CreateAndUpdateNoteUiState.Content -> Content(
                    title = uiState.title,
                    description = uiState.description,
                    onTitleChanged = onTitleChanged,
                    onDescriptionChanged = onDescriptionChanged,
                    onSaveButtonClick = onSaveButtonClick,
                    navigateToHomeScreen = navigateToHomeScreen
                )
            }
        }
    }
}

И последнее что у нас осталось это – CreateAndUpdateNoteRoute:

@Composable
internal fun CreateAndUpdateNoteRoute(
    modifier: Modifier = Modifier,
    viewModel: CreateAndUpdateNoteViewModel = hiltViewModel(),
    topBarTitle: Int?,
    navigateToHomeScreen: () -> Unit
) {

    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    CreateAndUpdateNoteScreen(
        modifier = modifier,
        uiState = uiState,
        topBarTitle = topBarTitle,
        onSaveButtonClick = { viewModel.handleEvent(CreateAndUpdateNoteUiEvent.OnSaveClicked) },
        navigateToHomeScreen = navigateToHomeScreen,
        onTitleChanged = { viewModel.handleEvent(CreateAndUpdateNoteUiEvent.OnTitleChanged(it)) },
        onDescriptionChanged = { viewModel.handleEvent(CreateAndUpdateNoteUiEvent.OnDescriptionChanged(it)) }
    )
}

Закончим этот раздел, реализацией навигации, в presentation создаем пакет navigation, где будет храниться навигация и в нем создаем файл CreateAndUpdateNoteNavigation, в котором создаем 3 константы и один enum класс:

const val CREATE_AND_UPDATE_NOTE_ROUTE = "create_and_update_note_route"
const val NOTE_ID_ARG = "note_id"
const val TOP_BAR_TITLE_ARG = "top_bar_title"
  • CREATE_AND_UPDATE_NOTE_ROUTE — название или id маршрута на экран создания или редактирования заметки

  • NOTE_ID_ARG — аргумент для передачи id заметки

  • TOP_BAR_TITLE_ARG — аргумент для передачи заголовка верхней панели (TopBar)

Далее, ниже констант создаем enum class CreateAndUpdateNoteResArg:

enum class CreateAndUpdateNoteResArg {
    CREATE_NOTE, UPDATE_NOTE
}

С помощью него, мы будем определять какой заголовок должен быть у экрана в зависимости от того куда кликнет юзер, на заметку или на кнопку добавления заметки.

Теперь создадим extension-функцию для навигации на CreateAndUpdateNoteRoute:

fun NavController.navigateToCreateAndUpdateNote(
    topBarTitleResArg: CreateAndUpdateNoteResArg,
    noteId: String?
) {
    val topBarTitleResId = when(topBarTitleResArg) {
        CreateAndUpdateNoteResArg.CREATE_NOTE -> R.string.create_note_topbar_title
        CreateAndUpdateNoteResArg.UPDATE_NOTE -> R.string.update_note_topbar_title
    }
    navigate(
        route = "$CREATE_AND_UPDATE_NOTE_ROUTE/$topBarTitleResId/$noteId",
    ) {
        launchSingleTop = true
    }
}
  • topBarTitleResArg: принимает одно из двух значений — CREATE_NOTE для создания новой заметки или UPDATE_NOTE для редактирования

  • noteId: это id заметки

Данная функция формирует маршрут для навигации с использованием переданных аргументов и запускает экран через navigate().

Для корректной работы навигации, экран необходимо добавить в NavGraphBuilder:

fun NavGraphBuilder.createAndUpdateNoteScreen(navigateToHomeScreen: () -> Unit) {
    composable(
        route = "$CREATE_AND_UPDATE_NOTE_ROUTE/{$TOP_BAR_TITLE_ARG}/{$NOTE_ID_ARG}",
        arguments = listOf(
            navArgument(NOTE_ID_ARG) { type = NavType.StringType; nullable = true },
            navArgument(TOP_BAR_TITLE_ARG) { type = NavType.IntType }
        )
    ) { backStackEntry ->
        CreateAndUpdateNoteRoute(
            topBarTitle = backStackEntry.arguments?.getInt(TOP_BAR_TITLE_ARG),
            navigateToHomeScreen = navigateToHomeScreen
        )
    }
}

navArgument(NOTE_ID_ARG): этот аргумент передаёт id заметки, который может быть nullable (то есть отсутствовать при создании новой заметки).

navArgument(TOP_BAR_TITLE_ARG): передаёт id ресурса строки для TopBar, чтобы он мог отображать разные заголовки для создания и редактирования.

Навигация с помощью Navigation Compose. Модуль navigation

В этом разделе мы рассмотрим, как настроить навигацию в вашем Android-приложении с использованием Navigation Compose. Этот современный подход интегрирован с Jetpack Compose и позволяет удобно управлять переходами между экранами и передачей данных между ними. В корне проекта создаем пакет navigation и сразу же в нем файл Navigation.

Основой для навигации служит NavHost — контейнер, в котором определяются все маршруты экрана. Для управления переходами между экранами используется NavController. В файле Navigation создаем Composable Navigation, который будет содержать NavHost:

@Composable
fun Navigation() {
    val navController: NavHostController = rememberNavController()

    NavHost(navController = navController, startDestination = HOME_SCREEN_ROUTE) {

        homeScreen(
            navigateToCreateNoteScreen = {
                navController.navigateToCreateAndUpdateNote(CreateAndUpdateNoteResArg.CREATE_NOTE,null)
            },
            navigateToUpdateNote = {
                navController.navigateToCreateAndUpdateNote(CreateAndUpdateNoteResArg.UPDATE_NOTE, it)
            }
        )

        createAndUpdateNoteScreen { navController.navigateToHomeScreen() }
    }
}

В NavHost мы задали два маршрута: домашний экран (home) и экран создания и редактирования заметок (create_and_update_note). В каждом из них вызывается соответствующий экран приложения.

Чтобы навигация и в целом приложение заработали, Composable функцию Navigation необходимо вызвать в MainActivity:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JustNotesTheme(dynamicColor = false) {
                Navigation()
            }
        }
    }
}

Заключение

Вот мы и закончили разработку нашего небольшого, но масштабируемого проекта. За время разработки мы научились работать с такими архитектурными подходами, как Clean architecture и MVVM с элементами MVI.

Также поработали с такими современными технологиями как:

  • Jetpack Compose

  • Dagger Hilt

  • Room

Мы сделали хорошую работу, но конечно можно сделать еще лучше: написать Unit, UI-тесты, сделать из наших якобы модулей настоящие модули. Этим всем вы можете заняться самостоятельно или можете подождать пока будут выпущены новые статьи по этим темам ?

Спасибо всем, за внимание, надеюсь этот гайд был вам полезен. Я планирую написать еще несколько статей по Android-разработке, которые будут скорее всего основываться на этом же приложении. Таким образом вы будете видеть все этапы развития, от MVP до многофункционального приложения.

Буду рад, видеть вас в новых статьях! Еще раз всем спасибо!

Ах да, чуть не забыл, вот ссылка на репозиторий на GitLab: https://gitlab.com/just-notes/just-notes.

До встречи!

Комментарии (5)


  1. N707
    30.10.2024 03:41

    Спасибо за статью.

    Для новичков на стероидах?)