RecyclerView 올바른 사용 방법

컴퓨터공부/안드로이드 2020. 7. 22. 19:38 Posted by 아는 개발자

 

사진첩 내용이랑 리뷰 처럼 동일한 형태의 아이템을 리스트로 띄우고 있다

전화번호부, 구글 편지함, 사진첩 등등 요즘 출시되는 대부분의 앱에선 전화번호 정보나 사진과 같은 동일한 유형의 아이템을 리스트로 보여주는 뷰를 가지고 있다. 하나의 아이템은 간단하게 텍스트 정보만 가지는 것부터 사진, 동영상처럼 파일 크기를 많이 차지하는 미디어까지 서비스마다 다양한 방식으로 효과를 준다. 이 아이템 효과를 개발적인 측면에서 고려해보면 화면에 보여주려는 아이템이 차지하는 메모리 크기가 얼마 되지 않는다면 리스트에 붙인 모든 아이템을 바로 화면에 렌딩해도 크게 문제가 되진 않지만 파일 크기가 큰 미디어를 포함하는 아이템을 한번에 렌딩한다면 프로그램에서 순식간에 대량의 메모리를 차지하게되는 문제가 생긴다. 스크롤이 버벅거리다가 Out of Memory 가 발생해서 앱이 죽는 경우가 이런 경우다.

 

이런 경우를 위해 만들어진 라이브러리가 RecyclerView 클래스다. RecyclerView 클래스는 대량의 아이템을 리스트로 보여줄때 실제로 화면에 비춰지는 아이템만 렌딩하도록 만들어 앱에서 사용하는 메모리의 양을 최소화 했다. 실제로 구현해보면 100개의 아이템을 RecyclerView에 붙여도 화면에 바인딩 되는 아이템은 화면에 비춰지는 것과 캐싱용으로 그 근처에 있는 아이템인 7~8개 남짓이다. 나머지 아이템은 스크롤해서 가까이 이동할 때 읽어와 화면에 띄워줘서 메모리 공간을 절약하는 방식이다. RecyclerView가 대신 메모리 관리를 해주기 때문에 개발자는 화면에 뿌려줄 아이템만 찾아주는데 집중하면 된다.

 

RecyclerView를 잘못 사용하는 경우는 RecyclerView의 장점인 메모리 관리 기능을 살리지 않고 사용할 때다. 화면에 비춰지는 것들만 화면에 렌딩해야하는데 잘못 사용하다보면 화면에 비춰지지 않는 아이템들도 바인딩 시켜버려서 메모리를 급격하게 잡아 먹게돼 폰이 갑자기 느려지는 경우가 생긴다. 물론 이런 유형의 버그를 만드는 것도 쉽진 않다. 하지만 화면에 다양한 유형의 아이템을 넣으려고 하다 보면 아래의 같은 구조로 코드를 짜게 되는데 이런 구조가 대표적으로 recyclerview를 잘못 쓸 수도 있게 되는 예다 (무조건 잘못하는 건 아니고)

 

 

위 그림에서 Parent RecyclerView는 Child RecyclerView 하나씩 나눠서 하게 된다. 그런데 Parent RecyclerView의 입장에서는 Child Recycler View가 가지고 있는 아이템이 얼마나 되는지는 모른다. 단지 첫번째 Child RecyclerView가 화면에 비치기만 한다면 각각이 갖고 있는 아이템의 개수와 상관 없이 화면에 바로 렌딩을 하게 된다. 여기서 만약 Child RecyclerView가 렌딩해야하는 총 크기가 화면 뷰를 벗어나지 않는다면 크게 문제가 되진 않는데 이를 벗어나 갖고 있는 모든 아이템을 렌딩하면 문제가 될 수도 있게 된다

 

 

윗 사진의 화면 페이지와 로그를 비교해보면 문제가 되는 것을 확인 할 수 있다. 실제로 화면에서는 0 ~ 7번까지만 화면에 보이고 있는데 로그에서는 불과 200ms 만에 21번까지 화면에 바인딩이 되버린다. 화면에서는 스트링 정보만 있기 때문에 스크롤 할 때 큰 문제가 되진 않는데 만약 리스트에 사진파일이나 동영상이 있으면 심각하게 스크롤이 잘 되지 않는 문제가 생긴다 (심지어 고성능 폰에서도 말이다) 

 

렉걸리는 문제에 대한 스택 오버플로우 답변으로는 각 아이템이 Constraint Layout을 없애서 아이템 하나가 차지하는 메모리의 크기를 줄이라고 하는데, 요새 폰들은 뷰의 부하 정도는 거뜬히 이겨낼 수 있을 정도로 좋아서 이부분이 그렇게까지 문제가 되는 것 같지는 않고 마지막에 최적화 할 부분이 없을 때 사용할 만한 팁인 정도다. 예상외로 심각하게 렉이 발생한다면 위 사례처럼 화면에 보이지 않는 아이템까지 바인딩하면서 메모리를 잡아먹고 있는것은 아닌지 확인하는게 좋을 것 같다.

 

 

Hilt를 소개하는 글을 쓰면서 알게된 Hilt의 여러가지 장점을 직접 몸소 체험해보고자 최근에 진행중인 프로젝트에서 쓰고 있는 Dagger를 Hilt로 마이그레이션을 해보려고 시도 했는데 중도에 포기했다. 구글 문서에서 마이그레이션 절차를 하나하나 자세히 설명해주고 있어 정보가 부족한 문제는 없었다. 그런데 문서를 차근차근 읽으면 읽을 수록 현재 서비스의 릴리즈 속도와 문서에서 해야하는 일들을 고려해봤을 때 이건 현재로선 도저히 손을 댈 수 없는 작업인 것 같았다. 이번 포스트에서는 내가 왜 마이그레이션을 할 수 없었는 지를 짤막하게 다뤄보려고 한다.

 

1. All or Nothing

 

Dagger와 Hilt는 하나의 앱에서 공존할 수 없다

처음에는 Hilt로 마이그레이션 하는 작업이 라이브러리 단위로 될 줄 알았다. 그래서 가장 위험부담이 적은 라이브러리부터 적용하는 방향을 고려 했었다. 그런데 Hilt를 적용하기 위해선 우선 Application에 해당하는 클래스에 Hilt 어노테이션을 추가해줘야했고, 이거를 붙이면 Dagger를 적용할 수 없게 된다. 그러니까 이 말의 뜻은 Hilt를 쓰려면 라이브러리 단위로 바꿔줄 수는 없고 모든 라이브러리에서 사용중인 Dagger코드를 Hilt로 바꿔줘야한다는 것이었다. 이게... 불가능 한 건 아닌데 Dagger의 단점으로 발생한 수 많은 boiler plate 코드를 어느 세월에 모두 바꿔치기 할 것인가...? 현재 진행 중인 프로젝트가 릴리즈 한지 얼마 안됐긴 했지만 그럼에도 불구하고 그 수많은 코드를 치우는게 압박감으로 다가왔었는데 큰 규모의 프로젝트는 더더욱 힘들 것 같다.

 

2. 역사가 짧은 라이브러리

 

"모든 Dagger 코드를 Hilt로 전환 하더라도 한 번 해보자!" 하는 마음이었으나 Hilt를 구글에 검색한 결과를 보고 바로 포기했다. 버전 명은 2.28 이라 많은 개선이 이뤄진 것 같은 걸로 보여지는데 안드로이드 Developer 미디엄에서는 이제야 "Hilt 써보세요" 하고 소개하고 있고 구글에서 검색한 결과도 Hilt를 간략하게 소개하는 글들만 있을 뿐 실제로 어떻게 사용해야 하는지에 대해서는 빈약해보인다. 다들 간만 보고 있는 건지 아니면 적용하고 있는건지 모르겠다. 스택오버플로우 질문도 얼마 없는 이 시점에서 함부로 들어 갔다간 구글도 모르는 버그를 경험하다가 프로젝트가 시간을 보낼 것 같아 무서웠다. 아무래도 배포후 사람들이 많이 쓰는 시점에 도입을 고려하는게 좋을 것 같다는 생각이 든다. 

 

3. 새롭게 시작할 때 사용하기

 

마이그레이션은 어렵지만 그래도 Hilt는 Dagger가 갖고 있던 단점을 보완한 훌륭한 라이브러리라는 점은 변함이 없다. 지금 당장 도입하기는 어렵지만 부담 없는 사이드 프로젝트에서 적용하면서 경험해보고 많은 사람들이 사용하면서 스택 오버플로우에 정보가 많아질 때 쯤 도입을 해본다면 괜찮을 것 같다.

 

 

현재 안드로이드 의존성 주입 라이브러리로는 2017년도 Jetpack에서 소개된 Dagger가 가장 유명하다. Dagger는 컴파일타임에 의존성 여부를 판단하는 방식으로 빌드 시간만 조금 길어지는 단점만 제외하면 성능적인 이슈가 없고 자유자재로 의존성을 관리할 수 있어 가지고 있는 기능만 본다면 완벽한 것 같았으나 클래스 하나를 Inject 시키기 위해 너무 많은 Boiler Plate 코드를 만들어야 하며 프로젝트에 도입하기 전에 공부해야 할 게 너무 많아 지치고 바쁜 클라이언트 개발자들이 당장 사용하기엔 불편하다는 피드백을 많이 받았다. 구글에서는 이런 불편사항들을 반영해 개발자들이 좀 더 쉽게 쓸 수 있는 Hilt라는 것을 만들었다.

 

Dagger를 기반으로 만든 라이브러리기 때문에 Dagger의 장점인 컴파일 타임의 의존성 체크, 자유로운 의존성 주입, 성능상의 이점은 그대로 가져가고 주안점으로 둔 Boiler Plate 코드 생성 작업은 최소화 시켰다. 최근에 시간이 생겨서 구글에서 제공하는 예제를 직접 구현하면서 따라가봤는데 Dagger에서 번거롭거나 불필요하다고 느꼈던 코드들이 Hilt를 사용하면서 많이 줄어들게 됐고 필요하다고 느꼈던 기능이 도입돼서 앞으로 많은 개발자들이 사용하게 되지 않을까 싶다. 모든 개선 사항들에 대해서는 공식 문서를 참고하면 좋을 것 같고 이 포스트에서는 내가 주의깊게 보고 있는 대표적인 개선사항 몇가지 다뤄볼려고 한다. 

 

 

1. Compontent 인터페이스가 사라짐

 

Dagger에서는 AppComponent 인터페이스를 만들어서 DaggerAppComponent 클래스를 자동생성 했다. 이 클래스로 Application 클래스에서 Dagger를 사용하도록 설정하고 주입시킬 모듈을 등록할 수 있었다.

 

Dagger Code

@Singleton
@Component(
    modules = [
        AndroidSupportInjectionModule::class,
        AppModule::class,
        ActivityBuildersModule::class,
        FragmentBuildersModule::class
    ]
)
interface AppComponent: AndroidInjector<BaseApp> {
    @Component.Builder
    abstract class Builder : AndroidInjector.Builder<BaseApp>()
}

class BaseApp: DaggerApplication() {
    override fun applicationInjector(): AndroidInjector<out DaggerApplication> {
        return DaggerAppComponent.builder().create(this)
    }

 

Hilt에서는 @HiltAndroidApp 어노테이션만 추가하면 이 앱은 Hilt 라이브러리를 사용하는 것으로 설정 할 수 있다.

 

Hilt Code

@HiltAndroidApp
class MainApp: Application() {}

 

2. Activity, Fragment에 대한 Dagger 모듈을 생성할 필요가 없어짐.

 

Activity, Fragment 같은 Lifecycle 클래스에서 Dagger를 쓰려면 아래처럼 일일이 모듈에다가 선언을 해줬어야 했었다. 새로운 화면을 만들 때마다 생성해야해서 정말 번거로운 작업이었다.

 

Dagger Code

@Module
abstract class DaggerFragmentModule {
    @Module
    abstract class StartModule {
        @Binds
        @FragmentScope
        abstract fun provideFragment(fragment: StartFragment): Fragment
    }

 

이제는 Fragment 클래스 위에  @AndroidEntryPoint 어노테이션만 붙여주면 된다. 이 클래스에 대해서는 의존성 주입 작업을 넣겠다는 뜻이 된다.

 

Hilt Code

@AndroidEntryPoint
class LogsFragment : Fragment() {

 

3. 모든 모듈은 자동 빌드

 

Dagger에선 모듈 클래스는 Dagger 라이브러리로 빌드하려면 최종적으로 AppComponent의 모듈에 등록해야했다. 아래 코드에선 SystemModule -> AppModule -> AppComponent 로 포함관계로 SystemModule이 적용된다. 아래 코드만 보면 별거 아니긴 하지만 은근히 깜빡하는 경우가 많아 빌드할 때 빨간색 에러를 자주 뿜던 곳이었다.

 

Dagger Code

@Singleton
@Component(
    modules = [
        AndroidSupportInjectionModule::class,
        AppModule::class,
        ActivityBuildersModule::class,
        FragmentBuildersModule::class
    ]
)
interface AppComponent: AndroidInjector<BaseApp> {

@Module(includes = [SystemModule::class])
abstract class AppModule {
    @Binds
    @Singleton
    abstract fun bindContext(application: BaseApp): Context
}

@Module
class SystemModule {
    @Provides
    fun provideContentResolver(context: Context): ContentResolver {
        return context.contentResolver
    }
}

 

그런데 Dagger에서는 이런 너저분한(?) 포함관계는 안만들어도 되고 @Module 어노테이션 앞에 @InstallIn 어노테이션만 추가해주면 알아서 빌드가 된다. 

 

Dagger Code

@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {
    @Binds
    abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

 

4. Application, Activity, Fragment 범주 선언이 쉬워짐

 

앞서 3에서 나온 코드에 @InstallIn 어노테이션을 활용하면 내가 주입할 클래스가 Application 범위인지, Activity 범위인지, Fragment 범위인지를 쉽게 표현해줄 수 있다. 이렇게 범위를 잡아주면 Hilt에서는 주입할 때 해당 라이프사이클 클래스에 맞는 객체를 생성해서 넣게 된다.

 

Hilt Code

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {
    @DatabaseLogger
    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {
    @InMemoryLogger
    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

@AndroidEntryPoint
class ButtonFragment: Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource // application component
    @Inject lateinit var navigator: AppNavigator // activity component

RxJava - debounce

컴퓨터공부/안드로이드 2020. 7. 4. 15:17 Posted by 아는 개발자

 

debounce 는 특정 시간이 지난 후에 마지막으로 들어온 이벤트만 받을 수 있는 오퍼레이터다. 글로 구구 절절 설명하는 것보다는 아래 그림으로 이해하는게 쉬울 것 같은데 아래 그림에서 최종적으로 받는 이벤트가 1, 5, 6인데 모두 입력됐을 때랑 입력으로 들어올 때랑 배출 될 때 사이의 구간 길이가 동일하다. 아래 그림에서 길이는 시간을 의미하기 때문에 둘다 동일한 시간이 지난 후에 이벤트를 받는 것으로 보면 된다. 그리고 5 이벤트가 들어오기 전에 2, 3, 4가 있었는데 최종적으로 5만 이벤트로 받게 되는데 이건 특정 시간이 지나기 전에 다른 이벤트들이 들어와서 그렇다. 그래서 2, 3, 4 이벤트는 모두 무시하게 된다.

 

 

debounce 오퍼레이터는 텍스트 입력과 관계된 작업에서 아주 요긴하게 써먹을 수 있다. 대표적으로 요즘 검색창 같은데선 유저의 입력을 받고 예상되는 입력 결과를 보여주는 UX가 많은데 매번 탕핑 할 때마다 서버를 치는것이 부자연스럽고 부하도 크다. 대체적으로 1초간 아무런 입력이 없었을 때 서버를 치도록 규칙을 넣어 놓는데 이때 debounce를 사용하면 쉽게 구현할 수 있다.

 

이 포스트에서는 서버를 치는 것은 없고 타이핑 한 내용이 1초 뒤에 TextView에 업데이트 되는 것으로 debounce 사용 예제를 만들어봤다. text watcher를 등록해서 타이핑한 내용이 변할 때마다 textChange 오브젝트에 이벤트를 보내고 debounce를 1초 걸어서 1초를 기다린 다음 마지막으로 들어온 스트링 값에 대해서 화면에 표현하도록 구현했다.

 

val textChange: PublishSubject<String> = PublishSubject.create()

fr_rx_edit.addTextChangedListener(object: TextWatcher{
    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
        textChange.onNext(s.toString())
    }
    override fun afterTextChanged(s: Editable?) {}
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
})

textChange
    .debounce(1, TimeUnit.SECONDS)
    .observeOn(AndroidSchedulers.mainThread())
    .doOnNext {
        fr_rx_tv.text = it
    }
    .subscribe()

 

결과 아래 그림처럼 타이핑 후 1초를 기다린 다음에 화면에 업데이트 된다.

 

RxJava - observeOn, subscribeOn

컴퓨터공부/안드로이드 2020. 6. 28. 18:00 Posted by 아는 개발자

 

RxJava의 observeOn, subscribeOn 함수를 이용하면 실행할 코드의 쓰레드 종류를 설정해줄 수 있다. 안드로이드의 경우 로컬 데이터 DB에 접근할 때는 IO 쓰레드를 사용해야하고 화면을 업데이트 할 때는 UI 쓰레드를 사용해야하며 이를 어기면 파란색 에러를 내뿜으면서 작업을 실행하지 않는데 RxJava의 observeOn, subscribeOn 함수를 사용하면 여기서 발생하는 오류들을 쉽게 해결할 수 있다.

 

일반적으로 subscribeOn 함수는 호출 시점 상위에 해당하는 부분의 쓰레드를, observeOn은 호출시점 하위 스트림의 쓰레드를 설정한다. 아래 코드 Single.fromCallable과 map 함수가 IO thread에서 실행되게 되고 하위 doOnSuccess와 doOnError는 mainThread를 따르게 된다. UI와 무관한 데이터 전처리 작업은 IO thread에서 실행하도록 설정하고 그 이후 화면을 업데이트할 때 사용하는 코드는 mainThread에 둔다면 동작이 스무스한 애플리케이션을 만들 수 있다.

 

private fun getLong(): Single<Long> = Single.fromCallable {
    1L // IO thread
}
    .map { it + 10 } // IO thread
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .doOnSuccess {  } // main thread
    .doOnError {  } // main thread

 

subscribeOn은 상위 스트림 쓰레드를 결정하지 못하는 경우가 있다. 스택오버플로우에서는 이런 경우가 언제 발생하는지 그리고 어떻게 변경할 수 있는지 질의응답이 있는데 나는 개인적으로 일반적인 경우에서 벗어난 일이 발생한다면 나는 스트림을 잘못 구현한 것이라고 본다. 이럴 때는 너무 긴 스트림을 잘게 쪼개서 일반적인 경우를 따르도록 만드는게 좋다고 생각한다.

 

'컴퓨터공부 > 안드로이드' 카테고리의 다른 글

Hilt - Dagger를 이을 의존성 주입 라이브러리(1)  (0) 2020.07.08
RxJava - debounce  (0) 2020.07.04
RxJava - observeOn, subscribeOn  (0) 2020.06.28
RxJava - combineLatest  (0) 2020.06.28
status bar 영역 덮는 view 만들기  (0) 2020.06.24
Lottie 라이브러리  (0) 2020.06.24