안드로이드를 위한 클린 아키텍처
Android/Android Jetpack

안드로이드를 위한 클린 아키텍처

728x90
반응형

Clean Architecture에 대한 개념과

안드로이드에서 클린아키텍처를 적용한 구체적 사례를 통해

추상적인 개념으로만 알고있었던 개념을 더 자세히 알아보려고 합니다.

 

Clean Architecture 란?

클린 아키텍처의 개념과 중요성에 대해서 알아보도록 합시다. 

아키텍처는 복잡성을 관리해주기 때문에 특히 큰 프로젝트에서 중요합니다.

모든 아키텍처에는 애플리케이션의 복잡성을 관리한다는 하나의 공통 목표가 있습니다. 특히 대규모 프로젝트에서는 더욱 그렇습니다. 

다음은 클린 아키텍처의 대표적인 그림입니다.


원은 앱의 다양한 수준의 소프트웨어를 나타냅니다. 주목해야 할 두 가지 주요 사항이 있습니다.

  1. 추상화 원칙 : 가운데 원이 가장 추상적이며 바깥쪽 원이 가장 구체적이여햐 합니다. 추상화 원칙은 내부 원에는 비즈니스 논리가 포함되어야 하고, 외부 원에는 실제로 그 비즈니스 로직을 어플리케이션에서 구현하는데 필요한 세부적인 정보가 있도록 해야 합니다.
  2. 종속성 규칙 : 각 원이 가장 가까운 내부 원에만 의존할 수 있음을 지정합니다. 이것이 아키텍처가 작동하는 원리입니다.

앱 코드를 구조화할 때 아키텍처를 사용하는 추가 이점은 다음과 같습니다.

코드의 일부가 분리되어 재사용 및 테스트가 더 쉬워집니다.

다른 사람이 코드 작업을 하면 앱의 아키텍처를 배우고 더 잘 이해할 수 있습니다.

클린 아키텍처는 솔리드 원칙을 최대한 활용합니다.

더보기

5가지 디자인 원칙은 소프트웨어 디자인을 보다 이해하기 쉽고 유연하며 유지 관리하기 쉽게 만듭니다. 그 원칙은 다음과 같습니다.

  1. 단일 책임: 각 소프트웨어 구성 요소는 변경해야 할 단 하나의 이유, 즉 하나의 책임만 있어야 합니다.
  2. 개방-폐쇄: 용도를 중단하거나 확장을 수정하지 않고 구성 요소의 동작을 확장할 수 있어야 합니다.
  3. Liskov 대체: 한 유형의 클래스와 해당 클래스의 하위 클래스가 있는 경우 앱을 중단하지 않고 하위 클래스로 기본 클래스 사용을 나타낼 수 있어야 합니다.
  4. 인터페이스 분리: 클래스가 필요하지 않은 메서드를 구현하지 않도록 큰 인터페이스보다 작은 인터페이스를 많이 갖는 것이 좋습니다.
  5. 종속성 역전: 구성 요소는 구체적인 구현이 아닌 추상화에 의존해야 합니다. 또한 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 됩니다.

클린 아키텍처는 필요에 따라 적절한 레이어를 사용하여 레이어간 통신 및 추상화를 관리합니다.

클린 아키텍처가 얼마나 많은 레이어를 가져야 하는지에 대해서는 다양한 의견이 있습니다. 아키텍처는 정확한 레이어를 정의하지 않고 대신 토대를 마련합니다. 아이디어는 필요에 따라 레이어 수를 조정한다는 것입니다.
간단하게 하기 위해 5개의 레이어를 사용합니다.

  1. Presentation: UI와 상호 작용하는 레이어입니다.
  2. Use cases: 인터랙터라고도 합니다. 사용자가 트리거할 수 있는 작업을 정의합니다.
  3. Domain: 앱의 비즈니스 로직을 포함합니다.
  4. Data: 모든 데이터 소스의 추상적 정의.
  5. Framework: Android SDK와의 상호 작용을 구현하고 데이터 계층에 대한 구체적인 구현을 제공합니다.

녹색으로 표시된 레이어는 Android SDK에 따라 다릅니다.
다음은 MVVM과 결합된 클린 아키텍처의 개요를 제공하는 그래프입니다.


기억해야 할 가장 중요한 세 가지 사항은 다음과 같습니다.

  1. 레이어 간 통신: 외부 레이어만 내부 레이어에 종속될 수 있습니다.
  2. 레이어 수는 임의적입니다. 필요에 따라 사용자 지정합니다.
  3. 내부 서클에서는 사물이 더욱 추상적이 됩니다.

클린 아키텍처 사용의 장점:

  • 코드가 더 분리되고 테스트 가능합니다.
  • 프레임워크 및 프레젠테이션 계층을 교체하고 앱을 다른 플랫폼으로 이식할 수 있습니다.
  • 프로젝트를 유지 관리하고 새 기능을 추가하는 것이 더 쉽습니다.

클린 아키텍처 사용의 단점:

  • 더 많은 코드를 작성해야 하고 프로젝트를 진행하려면 Clean Architecture를 배우고 이해해야 합니다.

클린 아키텍처는 묘책 솔루션은 아니지만 모든 플랫폼에 대해 일반적일 수 있다는 점에 유의하는 것이 중요합니다. 필요에 맞는 경우 프로젝트를 기반으로 결정해야 합니다. 예를 들어, 프로젝트가 크고 복잡하고 많은 비즈니스 논리가 있는 경우 Clean 아키텍처는 분명한 이점을 제공합니다. 반면에 더 작고 간단한 프로젝트의 경우 이러한 이점이 가치가 없을 수 있습니다. 결국 더 많은 코드를 작성하고 모든 계층에 약간의 복잡성을 추가하여 더 많은 시간을 투자하게 될 것입니다. 

클린 아키텍처는 Android뿐만 아니라 모든 애플리케이션과 플랫폼에서 사용할 수 있기 때문에 그 이면의 아이디어를 이해면 오늘날 우리가 발견하는 대부분의 문제에 왜 좋은 솔루션인지에 대한 답을 알려줄 것 입니다.  

 

 

이 글에서는 클린 아키텍처 패턴을 사용하여 간단한 PDF 리더인 Majestic Reader 앱 구현해보려합니다.

앱은 두 개의 화면으로 구성됩니다.

  • Library - 라이브러리에 있는 PDF 문서 목록입니다.
  • Reader - PDF 문서 판독기.

현재 그것들은 더미 화면입니다. 귀하의 임무는 Clean Architecture를 사용하여 Library 및 Reader 기능을 구현하는 것입니다.

 

Project 구조- module 분리

먼저 클린 아키텍처의 계층을 크게 비즈니스 로직 및 기능을 포함하는 영역과 Android SDK에 종속되어있는 프레임워크 및 UI 부분이 포함되어있는 부분 두가지로 크게 분리하려 합니다. 

이렇게 두 가지 영역을 Android module 두가지로 나누어 구현함으로써 우리 앱의 기능들을 안드로이드 환경에 종속되지 않도록 구현할수 있게 됩니다.


프로젝트를 두 개의 module로 분할합니다.

  • app module : 기존 Android SDK가 포함되어 있는 모듈. DB, Network 등 Android 프레임워크와 UI가 포함되어 있음.
  • core module : Android SDK에 의존하지 않는 모든 코드를 포함하는 새로 만드는 모듈. 비즈니스 로직 및 앱 기능이 포함되어 있음. 

app 모듈이 이미 있으므로 core 모듈을 생성하여 시작합니다.

더보기

프로젝트 탐색기에서 MajesticReader를 마우스 오른쪽 버튼으로 클릭하고 New ▸ Module을 선택하거나 File ▸ New ▸ New Module을 선택합니다. 마법사에서 Java or Kotlin 라이브러리를 선택합니다.

 

Library name에 core를 입력하고
Package name에는 현재 패키지를 입력합니다.(예를들어 com.jslee.android.majesticreader)

그런 다음 Finish를 클릭합니다.


Java 클래스 이름을 무시하고 완료를 클릭합니다.

Gradle이 동기화될 때까지 기다립니다. 그런 다음 코어 모듈에서 build.gradle을 열고 첫 번째 적용 플러그인 줄 다음에 다음을 추가합니다.

apply plugin: 'kotlin'


종속성 섹션에서 종속성을 두 개 더 추가합니다.

implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1"


다음으로 app 모듈에서 core 모듈을 참조합니다.

app 모듈에서 build.gradle을 열고 종속성 블록 아래에 다음 줄을 추가합니다.

implementation project(':core')

다음으로 core 모듈에서 domain, data, interactor 패키지를 추가합니다.

더보기

New ▸ Package를 선택합니다. 새 패키지의 이름으로 Domain을 입력합니다.

Data와 interactor 패키지도 추가로 만들어 주세요
클린 아키텍처의 취지에 따라서 이제 각 계층을 독립적으로 구현해야 합니다. :)

 

1. Domain Layer

Domain model 모듈 분리

도메인 계층에는 앱의 모든 모델과 비즈니스 규칙이 포함됩니다.

기존 프로젝트에서 제공하는 Bookmark 및 Document 모델을 core 모듈로 이동합니다. 

더보기

앱 모듈에서 BookmarkDocument 파일을 선택하고 core 모듈의 com.raywenderlich.android.majesticreader.domain 패키지로 드래그합니다. 대화 상자가 표시됩니다.


Refactor를 클릭하여 프로세스를 마칩니다.

 

2. Data Layers

이 계층은 데이터베이스 또는 인터넷과 같은 데이터 소스에 액세스하기 위한 추상 정의를 제공합니다. 

이 레이어에서 Repository 패턴을 사용하여 종속성 역전원칙을 달성했습니다. 

더보기

Repository 패턴의 주요 목적은 데이터 액세스의 구체적인 구현을 추상화하는 것입니다. 이를 달성하기 위해 각 모델에 대해 하나의 interface와 하나의 class를 추가합니다.

  • DataSource interface: Framework 계층이 구현해야 하는 인터페이스입니다.
  • Repository class: DataSource에 위임된 데이터에 액세스하기 위한 메서드를 제공합니다.

리포지토리 패턴을 사용하는 것은 다음과 같은 이유로 종속성 역전 원칙의 좋은 예입니다.

  • 더 높고 더 추상적인 수준의 데이터 계층은 프레임워크, 하위 수준 계층에 의존하지 않습니다.
  • 리포지토리는 데이터 액세스의 추상화이며 세부 정보에 의존하지 않습니다. 추상화에 따라 다릅니다.

Repositories & DataSource 구현

BookMarkRepositoryDocumentRepository를 추가합니다.

그리고

각 Repository 에서 사용하고 있는 BookMarkDataSource, DocumentDataSource, OpenDocumentDataSource를 추가합니다.

 

더보기

 

코어 모듈에서 com.raywenderlich.android.majesticreader.data를 선택합니다. 마우스 오른쪽 버튼을 클릭하고 New ▸ Kotlin 파일/클래스를 선택하여 새 Kotlin 파일을 추가합니다.

대화 상자에 BookmarkDataSource를 입력합니다.



마침을 클릭합니다. 새 파일을 열고 첫 번째 줄 뒤에 다음 코드를 붙여넣습니다.

interface BookmarkDataSource {

  suspend fun add(document: Document, bookmark: Bookmark)

  suspend fun read(document: Document): List<Bookmark>

  suspend fun remove(document: Document, bookmark: Bookmark)
}

이 인터페이스를 사용하여 프레임워크 계층에서 책갈피 데이터 액세스를 제공합니다.

프로세스를 반복하고 DocumentDataSource라는 다른 인터페이스를 추가합니다.

interface DocumentDataSource {

  suspend fun add(document: Document)

  suspend fun readAll(): List<Document>

  suspend fun remove(document: Document)
}

프로세스를 반복하고 OpenDocumentDataSource라는 마지막 데이터 원본을 추가합니다.

interface OpenDocumentDataSource {

  fun setOpenDocument(document: Document)

  fun getOpenDocument(): Document
}

이 데이터 소스는 현재 열려 있는 PDF 문서의 저장 및 검색을 처리합니다. 그런 다음 BookmarkRepository라는 새 Kotlin 파일을 동일한 패키지에 추가하고 다음 코드를 복사하여 붙여넣습니다.

class BookmarkRepository(private val dataSource: BookmarkDataSource) {
  suspend fun addBookmark(document: Document, bookmark: Bookmark) =
    dataSource.add(document, bookmark)

  suspend fun getBookmarks(document: Document) = dataSource.read(document)

  suspend fun removeBookmark(document: Document, bookmark: Bookmark) =
    dataSource.remove(document, bookmark)	
}

이것은 앱에 저장된 북마크를 추가, 제거 및 가져오는 데 사용할 리포지토리입니다. suspend 수정자로 모든 메서드를 표시합니다. 이를 통해 더 간단한 스레딩을 위해 Room에서 코루틴 기반 메커니즘을 사용할 수 있습니다.

 

DocumentRepository를 추가합니다

class DocumentRepository(
    private val documentDataSource: DocumentDataSource,
    private val openDocumentDataSource: OpenDocumentDataSource) {

  suspend fun addDocument(document: Document) = documentDataSource.add(document)	

  suspend fun getDocuments() = documentDataSource.readAll()
  
  suspend fun removeDocument(document: Document) = documentDataSource.remove(document)

  fun setOpenDocument(document: Document) = openDocumentDataSource.setOpenDocument(document)	

  fun getOpenDocument() = openDocumentDataSource.getOpenDocument()
}

 

 

3. Usecase Layers

Use Cases 들 구현

이 계층은  use cases라고도 하는 사용자 작업을 응용 프로그램의 내부 계층으로 변환하고 전달합니다.

Majestic Reader에는 두 가지 주요 기능이 있습니다.

  • librarydocuments 목록을 표시하고 관리합니다.
  • 사용자가 document를 열고 bookmarks를 관리할 수 있습니다.

여기에서 사용자가 수행할 수 있어야 하는 작업을 나열할 수 있습니다.

  • 현재 열려 있는 documentsbookmarks를 추가합니다. 
  • 현재 열려 있는 documents에서 bookmarks를 제거합니다.
  • 현재 열려 있는 documents에 대한 모든 bookmarks를 가져옵니다.
  • library에 새 documents를 추가합니다.
  • library에서 documents를 제거합니다.
  • library에서 documents 가져오기.
  • 현재 열려 있는 documents를 설정합니다.
  • 현재 열려 있는 documents 가져오기.

다음 작업은 각  use cases를 나타내는 클래스를 만드는 것입니다.

더보기

interactors.kt에 AddBookmark라는 새 Kotlin 파일을 만듭니다. 패키지 문 뒤에 다음 코드를 추가합니다.

class AddBookmark(private val bookmarkRepository: BookmarkRepository) {
  suspend operator fun invoke(document: Document, bookmark: Bookmark) =
      bookmarkRepository.addBookmark(document, bookmark)
}

각 사용 사례 클래스에는 사용 사례를 호출하는 함수가 하나만 있습니다. 편의상 호출 연산자를 오버로드합니다. 이를 통해 AddBookmark.invoke() 대신 addBookmark()에 대한 AddBookmark 인스턴스의 함수 호출을 단순화할 수 있습니다.

 

이 절차를 반복하고 나머지 사용 사례 추가에 대한 클래스를 추가합니다.
AddDocument()

class AddDocument(private val documentRepository: DocumentRepository) {
  suspend operator fun invoke(document: Document) = documentRepository.addDocument(document)
}

GetBookmarks()

class GetBookmarks(private val bookmarkRepository: BookmarkRepository) {
  suspend operator fun invoke(document: Document) = bookmarkRepository.getBookmarks(document)
}

GetDocuments()

class GetDocuments(private val documentRepository: DocumentRepository) {
  suspend operator fun invoke() = documentRepository.getDocuments()
}

GetOpenDocument()

class GetOpenDocument(private val documentRepository: DocumentRepository) {
  operator fun invoke() = documentRepository.getOpenDocument()
}

RemoveBookmark()

class RemoveBookmark(private val bookmarksRepository: BookmarkRepository) {
  suspend operator fun invoke(document: Document, bookmark: Bookmark) = bookmarksRepository
      .removeBookmark(document, bookmark)
}

RemoveDocument()

class RemoveDocument(private val documentRepository: DocumentRepository) {
  suspend operator fun invoke(document: Document) = documentRepository.removeDocument(document)
}

SetOpenDocument()

class SetOpenDocument(private val documentRepository: DocumentRepository) {
  operator fun invoke(document: Document) = documentRepository.setOpenDocument(document)
}

이것으로 코어 모듈의 3개 내부 레이어 구현을 마칩니다. 

이제 나머지 레이어인 Framework 및 Presentation으로 이동할 준비가 되었습니다. 

두 레이어 모두 Android SDK에 의존하므로 앱 모듈에 배치합니다.

 

4. Framework Layer

프레임워크 계층은 데이터 계층에 정의된 interface를 구현하는 기능들 갖고있는 레이어입니다.
다음 작업은 Data Layer에서 DataSource interface 구현을 제공하는 것입니다. 

 

Framework에서는 Data Layer에서 Repository패턴을 통해 추상적으로 정의한 기능들을 구체적으로 구현하는 기능을 구현합니다.

Android SDK와 밀접한 관련이 있는 local 및 network 라이브러리에 대한 기능에 대한 구현을 하는 레이어 입니다.

또한 Local Datasource(RoomDatabase, Memory 등)과 Network Datasource(Retrofit, Http 통신 등)등 Data layer에서 추상적으로 정의한 기능을 구체화 합니다.

 

Data를 Memory에 저장하는 기능 구현

더보기

OpenDocumentDataSource는 현재 열려 있는 문서를 메모리에 저장하는 기능을 가지고 있습니다.

InMemoryOpenDocumentDataSource.kt를 구현하면 다음과 같습니다.

class InMemoryOpenDocumentDataSource : OpenDocumentDataSource {

  private var openDocument: Document = Document.EMPTY

  override fun setOpenDocument(document: Document) {
    openDocument = document
  }

  override fun getOpenDocument() = openDocument 
}

이것은 데이터 계층에서 OpenDocumentDataSource를 구현한 것입니다. 현재 열려 있는 문서는 메모리에 저장되므로 구현이 매우 간단합니다.

 

DataSources 구현

Room 라이브러리를 사용하여 나머지 데이터 소스를 사용하여 데이터베이스의 데이터를 위임하고 유지합니다. Room을 통해 북마크 및 문서를 유지하는 데 필요한 클래스는 db 하위 패키지에 있습니다.

 

더보기

프레임워크에서 RoomBookmarkDataSource라는 새 Kotlin 파일을 만듭니다. 새 파일에 다음 코드를 추가합니다.

 

class RoomBookmarkDataSource(context: Context) : BookmarkDataSource {

  // 1 MajesticReaderDatabase를 사용하여 BookmarkDao의 인스턴스를 가져와 로컬 필드에 저장합니다.
  private val bookmarkDao = MajesticReaderDatabase.getInstance(context).bookmarkDao()


  // 2 Room 구현에서 추가, 읽기 및 제거 메소드를 호출합니다.
  override suspend fun add(document: Document, bookmark: Bookmark) = 
    bookmarkDao.addBookmark(BookmarkEntity(
      documentUri = document.url,
      page = bookmark.page
    ))

  override suspend fun read(document: Document): List<Bookmark> = bookmarkDao
      .getBookmarks(document.url).map { Bookmark(it.id, it.page) }

  override suspend fun remove(document: Document, bookmark: Bookmark) =
    bookmarkDao.removeBookmark(
        BookmarkEntity(id = bookmark.id, documentUri = document.url, page = bookmark.page)
    )
}

코드가 수행하는 작업은 단계별로 다음과 같습니다.

프레임워크에서 RoomDocumentDataSource라는 새 Kotlin 파일을 만듭니다. 새 파일에 다음 코드를 추가합니다.

class RoomDocumentDataSource(val context: Context) : DocumentDataSource {

  private val documentDao = MajesticReaderDatabase.getInstance(context).documentDao()

  override suspend fun add(document: Document) {
    val details = FileUtil.getDocumentDetails(context, document.url)
    documentDao.addDocument(
        DocumentEntity(document.url, details.name, details.size, details.thumbnail)
    )
  }

  override suspend fun readAll(): List<Document> = documentDao.getDocuments().map {
    Document(
        it.uri,
        it.title,
        it.size,
        it.thumbnailUri
    )
  }

  override suspend fun remove(document: Document) = documentDao.removeDocument(
      DocumentEntity(document.url, document.name, document.size, document.thumbnail)
  )
}

이제 남은 일은 모든 점을 연결하고 데이터를 표시하는 것입니다.

 

 

5. Presentation Layer

이 계층에는 사용자 인터페이스 관련 코드가 포함되어 있습니다. 이 레이어는 프레임워크 레이어와 같은 원에 있으므로 해당 클래스에 의존할 수 있습니다.

 

MVVM 사용

Android Jetpack에서 지원하기 때문에 이 레이어에서 MVVM 패턴을 사용하게 됩니다. 클린 아키텍처를 구현하는 입장에서는 이 레이어에 어떤 패턴을 사용하는지는 중요하지 않으며 MVP, MVI 또는 다른 어떤 것이든 필요에 가장 적합한 패턴을 자유롭게 사용할 수 있습니다.

더보기

MVVM 다이어그램입니다.


MVVM 패턴은 세 가지 구성 요소로 구성됩니다.
View: 사용자에게 UI를 그리는 역할
Model: 비즈니스 논리 및 데이터를 포함합니다.
ViewModel: 데이터와 UI 사이의 다리 역할을 합니다.

클린 아키텍처에서는 모델에 의존하는 대신 사용 사례 계층에서 인터랙터와 통신합니다.

종속성 주입 사용 

프레젠테이션 계층 구현으로 이동하기 전에 데이터 계층에 데이터 소스를 제공하는 방법이 필요합니다. 일반적으로 종속성 주입을 사용하여 이 작업을 수행해야 합니다. 종속성에 대한 제공자 기능 또는 팩토리를 분리하고 사용하는 프로세스입니다. 이렇게 하면 생성자에서 종속성을 생성하지 않으므로 클래스가 더 깔끔해집니다.

간단하게 유지하기 위해 ViewModel에 종속성을 제공하는 쉬운 방법을 수동으로 구현합니다.

더보기


먼저 프레임워크 네임스페이스의 빈 Interactors 클래스를 모든 인터랙터를 포함하는 데이터 클래스로 바꿉니다.

data class Interactors(
    val addBookmark: AddBookmark,
    val getBookmarks: GetBookmarks,
    val deleteBookmark: RemoveBookmark,
    val addDocument: AddDocument,
    val getDocuments: GetDocuments,
    val removeDocument: RemoveDocument,
    val getOpenDocument: GetOpenDocument,
    val setOpenDocument: SetOpenDocument
)

ViewModels에서 인터랙터에 액세스하는 데 사용할 것입니다.


MajesticReaderApplication을 열고 onCreate()를 다음으로 대체하여 필요한 가져오기를 모두 추가했는지 확인합니다.

override fun onCreate() {
  super.onCreate()

  val bookmarkRepository = BookmarkRepository(RoomBookmarkDataSource(this))
  val documentRepository = DocumentRepository(
      RoomDocumentDataSource(this),
      InMemoryOpenDocumentDataSource()
  )

  MajesticViewModelFactory.inject(
      this,
      Interactors(
          AddBookmark(bookmarkRepository),
          GetBookmarks(bookmarkRepository),
          RemoveBookmark(bookmarkRepository),
          AddDocument(documentRepository),
          GetDocuments(documentRepository),
          RemoveDocument(documentRepository),
          GetOpenDocument(documentRepository),
          SetOpenDocument(documentRepository)
      )
  )
}

이렇게 하면 MajesticViewModelFactory에 모든 종속성이 주입됩니다. 앱에서 ViewModel을 생성하고 상호작용 종속성을 여기에 전달합니다.

의존성 주입에 필요한 모든 것이 끝났습니다. 이제 프레젠테이션 레이어로 돌아갑니다.

Document load 구현

LibraryViewModel 에서 MVVM을 구현합니다. 

더보기

LibraryViewModel.kt를 엽니다.
ViewModel에는 문서 목록을 로드하고 목록에 새 문서를 추가하는 기능이 포함되어 있습니다. UI와 인터랙터 또는 사용 사례 간의 연결 역할을 합니다.

먼저 loadDocuments()를 다음으로 바꿉니다.

 

fun loadDocuments() {
  GlobalScope.launch {	
    documents.postValue(interactors.getDocuments())
  }
}

이는 launch()를 호출하여 시작하는 코루틴 내에서 GetDocuments 인터랙터를 사용하여 라이브러리에서 문서 목록을 가져옵니다. 완료되면 LiveData 문서에 결과를 게시합니다.

 

다음으로 addDocument()의 경우 새 문서를 추가한 후 loadDocuments()를 추가로 호출하려고 합니다.

fun addDocument(uri: Uri) {
  GlobalScope.launch {	  
    withContext(Dispatchers.IO) {	
      interactors.addDocument(Document(uri.toString(), "", 0, ""))
    }
    loadDocuments()
  }
}

새 문서를 추가하려면 먼저 이전과 같이 코루틴을 시작한 다음 withContext()를 사용하여 데이터베이스 삽입을 IO 최적화 스레드로 이동하고 삽입이 완료될 때까지 일시 중지합니다.

그리고 나서 Document를 다시 로드하여 목록을 업데이트합니다.

마지막으로 setOpenDocument()는 그에 해당하는 인터랙터를 호출합니다.

fun setOpenDocument(document: Document) {
  interactors.setOpenDocument(document)
}

이제 앱을 빌드하고 실행합니다. 이제 라이브러리에 새 문서를 추가할 수 있습니다! :)

floating action 버튼을 누릅니다. Repository에서 Document를 선택하는 화면이 표시됩니다. Document를 추가하면 list에 표시됩니다.

 

Documents reader & Bookmark구현

ReaderViewModel을 엽니다. 

다음은 ReaderFragment가 사용자 작업에 대해 호출하는 함수가 포함된 ReaderViewModel의 개요입니다.

  • openDocument(): PDF 문서를 엽니다.
  • openBookmark(): 문서에서 주어진 책갈피로 이동합니다.
  • openPage(): 문서에서 지정된 페이지를 엽니다.
  • nextPage(): 다음 페이지로 이동합니다.
  • previousPage(): 이전 페이지로 이동합니다.
  • toggleBookmark(): 문서 책갈피에서 현재 페이지를 추가하거나 제거합니다.
  • toggleInLibrary(): 라이브러리에서 열려 있는 문서를 추가하거나 제거합니다.
  • ReaderFragment는 생성될 때 인수로 표시할 문서를 가져옵니다.
더보기

ReaderViewModel에서 첫 번째 // TODO 주석을 찾습니다. 그 자리에 다음 코드를 추가합니다.

addSource(document) { document ->
  GlobalScope.launch {
    postValue(interactors.getBookmarks(document))
  }
}

이렇게 하면 문서를 변경할 때마다 책갈피 값이 변경됩니다. 코루틴 내 인터랙터에서 가져온 최신 북마크로 채워집니다. 북마크 필드는 이제 다음과 같이 표시됩니다.

val bookmarks = MediatorLiveData<List<Bookmark>>().apply {
  addSource(document) { document ->
    GlobalScope.launch {
      postValue(interactors.getBookmarks(document))
    }
  }
}

문서는 Fragment 인수에서 구문 분석된 문서를 보유합니다. 책갈피는 현재 문서의 책갈피 목록을 보유합니다. ReaderFragment는 사용 가능한 북마크 목록을 얻기 위해 구독합니다.

PDFs 렌더링  구현

PDF 문서 페이지를 렌더링하려면 API 레벨 21부터 Android SDK에서 사용할 수 있는 PdfRenderer를 사용하십시오.

currentPage는 현재 표시하는 PdfRenderer.Page에 대한 참조를 보유합니다(있는 경우). renderer는 문서를 렌더링하는 데 사용되는 PdfRenderer에 대한 참조를 보유합니다. 문서의 내부 값을 변경할 때마다 문서에 대한 PdfRenderer의 새 인스턴스를 만들고 렌더러에 저장합니다.

hasPreviousPage 및 hasNextPage는 currentPage에 의존합니다. 그들은 LiveData 변환을 사용합니다. hasPreviousPage는 currentPage의 인덱스가 0보다 크면 true를 반환합니다. hasNextPage는 currentPage의 인덱스가 페이지 수에서 1을 뺀 것보다 작은 경우(사용자가 끝에 도달하지 않은 경우) true를 반환합니다. 그런 다음 이 데이터는 ReaderFragment에서 UI가 표시되고 작동하는 방식을 지정합니다.

Library 기능 구현

isCurrentPageBookmarked()는 현재 표시된 페이지에 대한 책갈피가 존재하는 경우 true를 반환합니다. isInLibrary()를 찾습니다. 열려 있는 문서가 이미 라이브러리에 있으면 true를 반환해야 합니다. 

더보기

다음으로 교체하십시오.

private suspend fun isInLibrary(document: Document) = 
    interactors.getDocuments().any { it.url == document.url }

그러면 GetDocuments를 사용하여 라이브러리의 모든 문서 목록을 가져오고 현재 열려 있는 문서와 일치하는 문서가 포함되어 있는지 확인합니다. 이것은 정지 기능이므로 isInLibrary LiveData 코드를 다음과 같이 변경하십시오.

val isInLibrary: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>().apply {
  addSource(document) { document -> 
    GlobalScope.launch { 
      postValue(isInLibrary(document)) 
    } 
  }
}

결국 LiveData 관계는 정말 간단합니다. isBookmarked는 isCurrentPageBookmarked()에 의존합니다. 현재 페이지에 대한 책갈피가 있으면 true입니다. 문서, currentPage 또는 책갈피가 변경될 때마다 isBookmarked도 업데이트 및 변경을 수신합니다.

// 1 currentPage를 초기화하여 첫 번째 페이지 또는 첫 번째 북마크된 페이지(있는 경우)로 설정합니다.
currentPage.apply {
  addSource(renderer) { renderer -> 
    GlobalScope.launch {
      val document = document.value

      if (document != null) {	
        val bookmarks = interactors.getBookmarks(document).lastOrNull()?.page ?: 0
        postValue(renderer.openPage(bookmarks))	
      }
    }
  }
}

// 2 ReaderFragment에 전달된 문서를 가져옵니다.
val documentFromArguments = arguments.get(DOCUMENT_ARG) as Document? ?: Document.EMPTY

// 3 GetOpenDocument에서 열린 마지막 문서를 가져옵니다.
val lastOpenDocument = interactors.getOpenDocument()

// 4 문서의 값을 ReaderFragment에 전달된 값으로 설정하거나 아무것도 전달되지 않은 경우 lastOpenDocument로 폴백합니다.
zSetOpenDocument를 호출하여 새로 열린 문서를 설정합니다.
document.value = when {
  documentFromArguments != Document.EMPTY -> documentFromArguments
  documentFromArguments == Document.EMPTY && lastOpenDocument != Document.EMPTY -> lastOpenDocument
  else -> Document.EMPTY
}

// 5 SetOpenDocument 를 호출하여 새로운 Document를 엽니다.
document.value?.let { interactors.setOpenDocument(it) }

Documents 열기 및 북마크 구현

다음으로 openDocument()를 구현합니다. 

더보기

다음 코드로 바꿉니다.

fun openDocument(uri: Uri) {
  document.value = Document(uri.toString(), "", 0, "")
  document.value?.let { interactors.setOpenDocument(it) }
}

이렇게 하면 방금 열린 문서를 나타내는 새 문서가 만들어지고 SetOpenDocument에 전달됩니다.


다음으로, toggleBookmark()를 구현합니다. 다음으로 바꿉니다.

fun toggleBookmark() {
  val currentPage = currentPage.value?.index ?: return
  val document = document.value ?: return
  val bookmark = bookmarks.value?.firstOrNull { it.page == currentPage }

  GlobalScope.launch {
    if (bookmark == null) {
      interactors.addBookmark(document, Bookmark(page = currentPage))
    } else {
      interactors.deleteBookmark(document, bookmark)
    }

    bookmarks.postValue(interactors.getBookmarks(document))
  }
}

이 기능에서는 북마크가 이미 데이터베이스에 있는지 여부에 따라 북마크를 삭제하거나 추가한 다음 북마크를 업데이트하여 UI를 새로 고칩니다.

마지막으로 toggleInLibrary()를 구현합니다. 다음으로 바꿉니다.

fun toggleInLibrary() {
  val document = document.value ?: return

  GlobalScope.launch {	
    if (isInLibrary.value == true) {
      interactors.removeDocument(document)
    } else {
      interactors.addDocument(document)
    }
  
    isInLibrary.postValue(isInLibrary(document))
  }
}

 

이제 앱을 빌드하고 실행합니다. 이제 탭하여 라이브러리에서 문서를 열 수 있습니다! :)

 

 

728x90
반응형