다크모드 적용기

개발/안드로이드 2021. 10. 14. 20:00 Posted by 아는 개발자

최근 기존 프로젝트에 다크모드를 적용하는 일을 진행했었다. 꽤 오랜 시간이 소요될 줄 알았지만 안드로이드에서 제공하는 기능을 통해서 생각보다 빠르게 적용할 수 있었다. 이번 포스트에서는 다크모드를 적용하면서 유용했던 기능을 정리해봤다.

 

0. 동일한 이름으로

 

다크모드를 적용할 때 주요하게 변경하는 부분은 텍스트 색깔, 그림 파일 형태의 앱 내 아이콘, XML로 생성한 그림 파일(사각형이나 그라데이션 등등) 이다. 다크모드를 적용하더라도 각각의 파일 이름은 그대로 가져가야 앱 내에서 수정하는 부분을 최소화 할 수 있기 때문에 리소스의 이름은 동일하게 유지하고 라이트모드, 다크모드 각각의 상태에서는 다른 파일을 사용하도록 진행했다.

 

1. 아이콘 리소스 분리

 

안드로이드에서는 리소스 폴더에 키워드를 넣어서 앱의 Configuration에 따라 다른 리소스를 사용하도록 설정할 수 있다. 해상도에 따라서 다른 아이콘을 적용하고 싶은 경우에는 drawable-xhdpi, drawable-xxhdpi 이런식으로 적용할 수 있는데 다크모드를 사용하는 경우에는 night 키워드를 넣어서 분기할 수 있다. drawable-night-xhdpi 이런 형태의 폴더를 만들면 xhdpi 해상도에서 다크모드인 경우에 이 폴더의 아이콘을 사용하게 된다.

 

추가한 폴더

 

2. 색상 분리 작업

 

라이트모드에서 다크 모드로 전환 할 때 색깔별로 어느정도 규칙이 생긴다. 예를 들면 라이트 모드에서 검은색 텍스트를 사용했다면 다크 모드에서는 흰색으로 바뀌게 된다. 나의 경우엔 이 부분에 대해선 디자인 팀에서 가이드라인을 받았고 규칙에 맞게 모드에 따라서 색상 파일로 분리한 다음에 각각의 리소스에 등록해주었다.

 

색상 정보를 가지고 있는 파일은 colors.xml 이었고 values 폴더 내에 있었다. 아이콘과 마찬가지로 이 폴더도 values-night로 하면 다크모드일때만 바라보는 폴더를 만들 수 있었다. 이 안에 colors.xml 을 추가하고 동일한 색상 이름이 다른 hexa 값을 가지도록 변경했다.

 

<!-- values/colors.xml -->

    <color name="hashtag_selected">#07c3ff</color>
    
<!-- values-night/colors.xml -->

    <color name="hashtag_selected">#404245</color>

 

이렇게 구분한 색상 값을 뷰 클래스에 적용하면 모드의 상태에 따라 다른 색상을 보여줄 수 있다. 텍스트뷰나 XML로 만든 그림 파일에도 동일하게 적용 가능하다. 

 

<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/hashtag_selected" />
    <corners android:radius="18dp" />
</shape>
728x90

'개발 > 안드로이드' 카테고리의 다른 글

다크모드 적용기  (0) 2021.10.14
RxJava - Disposable Deep Dive!  (0) 2021.09.17
RxJava dispose()  (0) 2021.09.16
ListAdapter, DiffUtil  (0) 2021.08.20
Coroutine + Retrofit | Coroutine + Room  (0) 2021.07.22
suspend fun  (0) 2021.07.22

RxJava - Disposable Deep Dive!

개발/안드로이드 2021. 9. 17. 20:00 Posted by 아는 개발자

1. Disposable 클래스의 역할

 

 

RxJava 공식 문서에서는 Observable과 Observer의 관계를 위 그림으로 표현한다. Observable에서 데이터를 전달 할 때는 onNext() 함수가, 더이상 전달할 값이 없을 때는 onComplete() 함수가 마지막으로 에러가 발생하면 onError()가 호출되는 방식이다. 이런 설명 방식도 조금 디테일하게 분석해보면 Observable과 Observer 사이에 Disposable 객체를 추가하는 것이 조금 더 정확할 것 같다.

 

 

Disposable 객체는 Observable에서 노출할 자원을 갖고 있고 Observer에게 이벤트로 전달하는 객체다. 그래서 RxJava 내부 소스코드를 분석해보면 첫번째 그림에 보여진 Observabe -> Observer에서 호출되는 함수는 사실 Disposable를 구현한 클래스 객체 내부에서 호출되고 있다. Observable은 Disposable을 생성하기 전까지 스트림을 대신 관리해주는 클래스고 실질적으로 값을 보내는 작업은 Disposable 내부 클래스에서 실행되고 있다.

 

2. 짧은 RxJava 코드

 

구체적으로 설명하기 위해 짧은 RxJava 코드 실행시 생성되는 객체들의 연관관계를 그려봤다. 

 

 

1. Observable.Just 는 단일 아이템을 생성하는 Observable 객체다. 이 객체를 생성하면 ObservableJust가 생성된다. 

2. doOnNext는 앞서 받은 Observable 아이템을 처리하고자 생성하는 루틴인데, 스트림을 유지하고자 ObservableDoOnEach를 만들었다. doOnNext의 내부 루틴은 DoOnEachObserver 객체에서 처리한다. 

3. doOnError도 doOnNext와 마찬가지로 스트림을 유지하고자 ObservableDoOnEach를 만들었다. doOnErro 내부 루틴은 DoOnEachObserver에서 처리한다. 

4. subscribe()가 호출되면 아래 스트림부터 최상단 스트림까지 차례로 구독 관계가 형성된다. ObservableDoOnEach -> ObservableDoOnEach -> ObservableJust 순서로 subscribe가 재귀로 호출되면서 Observer 간의 구독 관계가 완성된다

5. 최상단 ObservableJust는 값 1을 발행하는데 이 이벤트는 ScalarDisposable 에서 담당한다. ObservableJust는 DoOnEachObserver 내부 onSubscribe 함수를 호출해서 스트림 간에 down/upstream 을 구축한다.

6. ScalarDisposable 내부에서는 DoOnEachObserver 내부 onNext 함수를 호출해서 값 1을 전달한다. 

 

3. ScalarDisposable

 

ScalarDisposable 에서 값을 전달하는 부분은 Runnable로 동작하게끔 구현되있다. Observable.just 형태의 스트림을 구독하면 매번 새로운 쓰레드가 생성되서 실행된다.

 

 

4. Memory Leak 가능성

 

다른 Disposable 를 구현한 클래스를 찾아보면 Observable의 역할에 따라서 Runnable인 경우도 있고 Scheduler로 돌리는 경우도 있다. Intervar 처럼 긴 시간 돌리는 작업이면 따로 dispose() 함수를 호출하지 않는 이상 쓰레드가 종료되지 않고 계속 실행된다. 이런 코드가 증가하게 되면 불필요한 쓰레드 개수가 늘어나 Memory Leak이 발생할 소지가 있다. RxJava()를 사용할 때 CompositeDisposable() 객체를 활용해서 dispose() 시키라는 이유가 여기에 있다.

728x90

'개발 > 안드로이드' 카테고리의 다른 글

다크모드 적용기  (0) 2021.10.14
RxJava - Disposable Deep Dive!  (0) 2021.09.17
RxJava dispose()  (0) 2021.09.16
ListAdapter, DiffUtil  (0) 2021.08.20
Coroutine + Retrofit | Coroutine + Room  (0) 2021.07.22
suspend fun  (0) 2021.07.22

RxJava dispose()

개발/안드로이드 2021. 9. 16. 20:00 Posted by 아는 개발자

이번 포스트에서는 RxJava를 사용할때 왜 dispose() 함수를 호출해서 메모리 정리를 해야하는지를 사례를 통해서 정리해보고자 한다.

 

class LeakActivity : AppCompatActivity() {
    private var disposable1 : Disposable? = null

    companion object {
        private const val TAG: String = "leak_activity_tag"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val observeSource = Observable.interval(1, TimeUnit.SECONDS)
        disposable1 = observeSource.subscribe { Log.d(TAG, "subscriber1 value: $it") }
        observeSource.subscribe { Log.d(TAG, "subscriber2 value: $it") }
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, "onDestroyCalled")
        disposable1?.dispose()
        disposable1 = null
    }
}

 

이 액티비티는 생성하면서 1초 마다 이벤트를 보내는 Observable을 생성하고 두개의 subscriber로 구독하고 있다. 그리고 종료될 때는 첫번째 subscriber만 구독 모델을 해지한다. Activity를 종료하기 전까지는 두 subscriber에서 동시에 로그가 출력되는데

 

2021-09-16 17:48:01.106 subscriber2 value: 0
2021-09-16 17:48:01.106 subscriber1 value: 0
2021-09-16 17:48:02.106 subscriber1 value: 1
2021-09-16 17:48:02.107 subscriber2 value: 1
2021-09-16 17:48:03.106 subscriber2 value: 2
2021-09-16 17:48:03.106 subscriber1 value: 2
2021-09-16 17:48:04.106 subscriber1 value: 3
2021-09-16 17:48:04.106 subscriber2 value: 3

 

액티비티를 종료하고 나면 subscriber2에서 계속 로그가 출력된다. 화면이 없어졌는데도 이벤트를 지속적으로 구독하고 있다.

 

2021-09-16 17:48:44.907 onDestroyCalled
2021-09-16 17:48:45.106 subscriber2 value: 44
2021-09-16 17:48:46.107 subscriber2 value: 45
2021-09-16 17:48:47.106 subscriber2 value: 46

 

심각한 것은 백버튼으로 앱을 종료한 후 다시 실행해도 계속 구독하고 있게 된다는 것이다.  아래 로그를 보면 subscriber2 로그가 두번씩 찍히는데 이것은 이전에 남아있는 액티비티에서 구독한 subscriber가 계속 출력되기 때문이다.

 

2021-09-16 17:50:58.964 subscriber2 value: 0
2021-09-16 17:50:58.964 subscriber1 value: 0
2021-09-16 17:50:59.106 subscriber2 value: 178
2021-09-16 17:50:59.964 subscriber1 value: 1
2021-09-16 17:50:59.965 subscriber2 value: 1
2021-09-16 17:51:00.106 subscriber2 value: 179
2021-09-16 17:51:00.963 subscriber1 value: 2
2021-09-16 17:51:00.963 subscriber2 value: 2
2021-09-16 17:51:01.106 subscriber2 value: 180
2021-09-16 17:51:01.963 subscriber2 value: 3
2021-09-16 17:51:01.963 subscriber1 value: 3
2021-09-16 17:51:02.106 subscriber2 value: 181

 

단발성 이벤트를 구독했다면 큰 문제는 되지 않는다 하지만 위 코드처럼 지속적으로 이벤트를 보낸다면 그리고 subscriber 내부에서 메모리 할당 작업이 포함돼있었다면 메모리 릭이 발생하게 된다. 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

다크모드 적용기  (0) 2021.10.14
RxJava - Disposable Deep Dive!  (0) 2021.09.17
RxJava dispose()  (0) 2021.09.16
ListAdapter, DiffUtil  (0) 2021.08.20
Coroutine + Retrofit | Coroutine + Room  (0) 2021.07.22
suspend fun  (0) 2021.07.22

ListAdapter, DiffUtil

개발/안드로이드 2021. 8. 20. 09:24 Posted by 아는 개발자

ListAdapter는 RecyclerView.Adapter의 확장기능으로 리스트내에 노출할 아이템의 변경 여부를 백그라운드 쓰레드에서 판단할 수 있는 기능을 제공한다. 생성자에는 DiffUtil.ItemCallback의 구체 클래스를 넘겨주는데 여기서 구현하는 두 함수를 이용해 아이템 변경 유무를 판단한다

 

class BookAdapter() : ListAdapter<Book, RecyclerView.ViewHolder>(object : DiffUtil.ItemCallback<Book>() {
    override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
        return oldItem.isbn13 == newItem.isbn13
    }

    override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
        return oldItem == newItem
    }
}

 

areItemsTheSame 은 두 아이템이 같은 것인지 확인한다. 아이템마다 고유의 id 가 있다면 두 아이템은 같은 것으로 볼 수 있다. 예제처럼 책을 리스트로 노출한다면 isbn이 동일할 때 같은 책인 것으로 볼 수 있다.

 

areContentsTheSame 은 같다고 알려진 두 아이템의 변경 유무를 확인 한다. return 값이 true면 동일한 것이고 false면 변경이 일어난 경우다. 같은 id 를 가졌더라도 클래스 내부의 값이 달라졌다면 변경이 된 것이기 때문에 다른 것으로 본다. 코드상에서처럼 두 객체를 비교해도 되고 성능을 올리려면 변경유무를 판단할 때 사용하는 속성값만 넣어도 된다. 책의 경우에는 책 제목이나 가격 속성을 이용해도 좋을 것 같다.

 

최종적으로 areContentsTheSame 이 false 인 item에 대해서만 onBindVieholder 함수가 호출 된다. Bind 함수 내에 많은 작업을 처리하는 경우 성능 개선에 도움이 될 것 같다.

 

주의사항

 

https://stackoverflow.com/a/50031492

 

ListAdapter not updating item in RecyclerView

I'm using the new support library ListAdapter. Here's my code for the adapter class ArtistsAdapter : ListAdapter<artist, artistsadapter.viewholder="">(ArtistsDiff()) { override fun</artist,>

stackoverflow.com

 

submitList로 리스트를 업데이트 할 때는 리스트 주소값도 바뀌어야 한다. ListAdapter에서 DiffUtil로 사용하는 AsyncListDiffer 클래스는 새로운 리스트가 기존 리스트와 같은 객체인 경우에는 값을 업데이트하지 않는다. 이는 곧 내부에 데이터를 아무리 바꿔도 리스트의 주소값이 동일하면 변경된 값을 화면에 노출시키지 않는다는 의미다. 이럴때는 깊은 복사(list deep copy)를 사용해 완전히 새로운 객체를 만드는 수밖에 없는데, 리스트 내 객체를 빼거나 추가하는 경우가 아니면 리스트 내부 객체까지 깊은 복사를 해야 리스트의 깊은 복사가 된다. 

 

public class AsyncListDiffer<T> {
    private final ListUpdateCallback mUpdateCallback;
    ...
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    public void submitList(@Nullable final List<T> newList,    
            @Nullable final Runnable commitCallback) {
        // incrementing generation means any currently-running diffs are discarded when they finish
        final int runGeneration = ++mMaxScheduledGeneration;

        if (newList == mList) {
            // nothing to do (Note - still had to inc generation, since may have ongoing work)
            if (commitCallback != null) {
                commitCallback.run();
            }
            return;
        }

 

성능을 위한 선택이었겠지만 내부 데이터 변경도 볼 수 없는 어댑터는 사용성이 크게 떨어지는 것 같다.

728x90

'개발 > 안드로이드' 카테고리의 다른 글

RxJava - Disposable Deep Dive!  (0) 2021.09.17
RxJava dispose()  (0) 2021.09.16
ListAdapter, DiffUtil  (0) 2021.08.20
Coroutine + Retrofit | Coroutine + Room  (0) 2021.07.22
suspend fun  (0) 2021.07.22
Single, Maybe, Completable  (0) 2021.07.04

Coroutine + Retrofit | Coroutine + Room

개발/안드로이드 2021. 7. 22. 21:00 Posted by 아는 개발자

Coroutine + Retrofit

 

Retrofit 2.6.0 버전부터 suspend 함수로 api를 작성할 수 있게 됐다. 다른 Retrofit 인터페이스처럼 어노테이션을 추가하고 suspend 함수를 추가하면 빌드 될 때 Retrofit 에서 전처리한다. 

 

interface LibraryApi {
    @GET("/1.0/new")
    suspend fun getNew() : BookListResp
}

 

suspend로 쓰였기 때문에, api 를 호출하는 부분에서도 suspend 함수를 받아서 처리할 수 있다. 예로 Repository 인 경우 suspend 함수를 이용해서 아래 코드로 표현이 가능하다. withContext를 받아서 I/O 쓰레드에서 실행하도록 변경해 Main 쓰레드 안전성이 보장됐다.

 

class LibraryRepository(
    private val apiProvider: ApiProvider
) {

    private val libraryApi by lazy { apiProvider.createApi(LibraryApi::class.java) }

    suspend fun loadNew(): BookListResp = withContext(Dispatchers.IO) {
        return@withContext libraryApi.getNew()
    }

 

Coroutine + Room 

 

Room에서 데이터 변화에 따라 UI를 바꾸거나 특정 로직을 실행해야할 때가 있다. Room 에서는 LiveData와 Flow를 이용하는 두가지 방법을 제공하는데, 이 포스트에서는 Flow를 활용한 버전만 다룬다. 

 

@Dao
interface BookSearchDao {
    @Query("select * from BookSearch order by BookSearch.createdAt desc")
    fun selectBookSearchList(): Flow<List<BookSearch>?>
    
class LibraryRepository(
    private val bookSearchDao: BookSearchDao
) {
    suspend fun loadBookSearchHistory(): Flow<List<BookSearch>?> = withContext(Dispatchers.IO) {
        return@withContext bookSearchDao.selectBookSearchList()
    }

 

Dao의 리턴타입을 Flow로 싸고 Repository 에서는 suspend 함수를 이용해 IO 쓰레드에서 실행하도록 변경하고 리턴했다. Flow는 RxJava의 Flowable과 같아서 내부 DB에 변경사항이 생기면 스트림을 따라서 알림을 준다. 

 

class SearchViewModel @Inject constructor(
    private val libraryRepository: LibraryRepository
): ViewModel() {
    fun loadHistory() {
        viewModelScope.launch {
            libraryRepository.loadBookSearchHistory()
                .distinctUntilChanged()
                .collect { list ->
                    searchHistory.value = list ?: listOf()
                }
        }
    }

 

ViewModel에선 loadBookSearchHistory() 함수의 리턴값인 Flow를 viewModelScope 내에서 subscribe 한다. 변화가 있을 때마다 collect 내부의 바디 코드가 실행된다. viewModelScop 으로 실행했기 때문에 ViewModel이 종료되면 subscribe도 자동으로 종료된다.

728x90

'개발 > 안드로이드' 카테고리의 다른 글

RxJava dispose()  (0) 2021.09.16
ListAdapter, DiffUtil  (0) 2021.08.20
Coroutine + Retrofit | Coroutine + Room  (0) 2021.07.22
suspend fun  (0) 2021.07.22
Single, Maybe, Completable  (0) 2021.07.04
Serializable 과 Parcelable  (0) 2021.06.19

suspend fun

개발/안드로이드 2021. 7. 22. 20:00 Posted by 아는 개발자

코틀린에서 추가된 suspend 함수는 Coroutine 내에서만 실행 가능한 함수다. 블로그 글마다 suspend 함수에 대해서 각각 정의가 다른데 나는 suspend 함수를 Coroutine Context를 갖고 있는 함수 정도로 정의하고 싶다.

 

간단한 사용법 

 

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        CoroutineScope(Dispatchers.Main).launch {
            val sum = suspendSum(1, 2) // no compile error  
            Log.d("suspend sum", sum.toString())
        }
        
        suspendSum(1, 2) // compile error
    }

    private suspend fun suspendSum(a: Int, b: Int) : Int {
        return a + b
    }
}

29210-29210/com.kwony.mylib D/suspend sum: 3

 

suspend 함수는 Coroutine Job 내에서 일반 함수처럼 호출이 가능하다. 그런데 외부에서는 부모의 Coroutine Context를 받지 않기 때문에 일반 함수처럼 호출이 안된다. Couroutine Context를 가지고 있는 점을 이용해서 아래 코드처럼 바꿀 수 있다. 

 

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        CoroutineScope(Dispatchers.Main).launch {
            val sum = suspendSum(1, 2)
            Log.d("mainactivity coroutine", Thread.currentThread().name)
            Log.d("mainactivity coroutine", sum.toString())
        }
    }

    private suspend fun suspendSum(a: Int, b: Int) : Int = withContext(Dispatchers.IO) {
        Log.d("mainactivity suspend", Thread.currentThread().name)
        return@withContext a + b
    }
    
    
mainactivity suspend: DefaultDispatcher-worker-2
mainactivity coroutine: main
mainactivity coroutine: 3

 

위 코드를 보면 suspend 함수 body가 withContext로 싸여져 있는 것을 볼 수 있다. 아래 코드를 실행 할 때는 withContext 함수를 이용해 쓰레드를 바꿔서 실행할 수 있다. suspend + withContext를 활용하면 특정 함수에 대해서 실행 쓰레드를 정해 Main 함수를 건드리지 않고 안전하게 실행할 수 있게 된다.

 

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        CoroutineScope(Dispatchers.Main).launch {
            val sum = suspendSum(1, 2)
            Log.d("mainsuspendsum", sum.toString())
        }
    }

    private suspend fun suspendSum(a: Int, b: Int) : Int = withContext(Dispatchers.IO) {
        val deferredSum = async { a + b }
        val deferredZero = async { 0}
        return@withContext deferredSum.await() + deferredZero.await()
    }

 

상황에 따라서 내부에 async 로 새로운 job을 생성해서 실행이 가능하다. suspend 함수는 내부가 Coroutine과 완전히 동일하다고 봐도 된다. 코드를 좀더 간결하게 쓸 수 있는 도구가 될 것 같다.

728x90

'개발 > 안드로이드' 카테고리의 다른 글

ListAdapter, DiffUtil  (0) 2021.08.20
Coroutine + Retrofit | Coroutine + Room  (0) 2021.07.22
suspend fun  (0) 2021.07.22
Single, Maybe, Completable  (0) 2021.07.04
Serializable 과 Parcelable  (0) 2021.06.19
kotlin lateinit, lazy by  (0) 2021.06.05

Single, Maybe, Completable

개발/안드로이드 2021. 7. 4. 17:59 Posted by 아는 개발자

RxJava2 들어오면서 Single, Maybe, Completable Observable이 추가됐다. 개발 할 때는 Single만 사용했었는데 이번 포스트에서 자주 쓰지 않은 Maybe, Completable에 대해서 배워보고 앞으로 용도에 맞춰서 사용해보려고 한다.

 

Single 

 

단일의 데이터 보내거나 에러를 내는 Observable이다. 주로 서버로부터 http 데이터를 받아올 때 사용한다. 아래 처럼 단일 데이터 객체인 Post를 받아오는 api를 사용한다면 Single로 Observable을 받고 doOnSuccess에서 받아온 데이터를 열어 볼 수 있다. 에러인 경우에는 doOnError를 실행한다

 

interface RxApi {
    @GET("api/v1/post")
    fun getPost(@Query("postId") postId: Long): Single<Post>
}

apiClient.getPost(postId)
    .doOnSuccess { post ->
        Log.d("rxtest", "post: ${post.postId}")
    }
    .doonError { }
    .subscribe()

 

Maybe 

 

Single처럼 단일의 데이터를 보내는데 빈 값을 보낼 수 있다는 점이 다르다. 서버에서 데이터를 읽는데 너무 오랜 시간이 걸리는 경우나 종종 빈 값을 내려줘야 하는 경우 요긴하게 쓸 수 있다. 빈 값이 내려오는 경우에는 doOnError 가 호출된다.

 

Maybe.empty<Int>()
    .doOnSuccess {
        Log.d("rxjava test", "printed: $it")
    }
    .doOnError { throwable ->
        Log.d("rxjava test", throwable.localizedMessage?: "")
    }
    .subscribe()

 

Completable 

 

함수의 리턴 값은 관심 없고 완료의 유무만 확인하고 싶을 때 사용하는 Observable이다. 서버에 객체를 업데이트하는 요청을 보낸 경우나 로깅용도로 사용하는 경우가 이런 경우에 적합하다.

 

interface RxApi {
    @POST("api/v1/log")
    fun logEvent(@Body param: String): Completable
}

apiClient.logEvent("this is blog post")
    .doOnComplete {
        Log.d("rxjava test", "finished time: ${System.currentTimeMillis()}")
    }
    .subscribe()
728x90

'개발 > 안드로이드' 카테고리의 다른 글

Coroutine + Retrofit | Coroutine + Room  (0) 2021.07.22
suspend fun  (0) 2021.07.22
Single, Maybe, Completable  (0) 2021.07.04
Serializable 과 Parcelable  (0) 2021.06.19
kotlin lateinit, lazy by  (0) 2021.06.05
Kotlin - Coroutine  (0) 2021.05.21

Serializable 과 Parcelable

개발/안드로이드 2021. 6. 19. 14:03 Posted by 아는 개발자

Serializable 

 

Serialization(직렬화)란 자바 시스템 내부에서 사용하는 객체를 외부의 자바 시스템에서도 사용할 수 있도록 byte형태로 데이터를 변환시키는 기술을 말하며 안드로이드 상에선 직렬화를 이용해 액티비티간 또는 서비스간 클래스 타입의 데이터를 주고 받는 용도로 주로 사용한다

 

아래 처럼 Student 클래스를 Serializable 선언 해준다면, 다른 액티비티에 클래스 형태 그대로 값을 전달 해줄 수 있다. Serializaable 선언 외에 추가하는 코드가 없어서 사용하기 정말 편리하다.

 

data class Student(val name: String, val age: Int) : Serializable

val intent = Intent().apply { this.putExtra("student", Student("kwony", 30)) }
startActivity(intent)

 

그러나 Serializable은 byte 형태로 변환된 데이터를 다시 객체의 형태로 변환시키는데 JVM 내부에서 임시 객체를 많이 만들게 되고 이 과정에서 garbage 가 생길 우려가 있다. 안드로이드의 경우에는 배터리 전력을 감소시키기도 한다는데 정확히 어느정도 영향이 있는지 수치가 나온것은 없다. 성능에 미치는 영향이 있다는 점은 기억해둘 필요가 있을 것 같다.

 

Parcelable 

 

자바 시스템 공용인 Serializable과 달리 Parcelable은 안드로이드 SDK에서 포함하는 인터페이스다. Serializable 이 갖고 있는 변환 과정에서의 성능 저하를 보완하기 위해 만들어졌는데 이 방법이 변환하는 부분을 개발자가 직접 하게끔(?) 하는 것이다. 그래서 Parcelable 인터페이스 상속 함수를 구현해야 하는데 아래 코드처럼 구현해야할 게 많아졌다.

 

data class Student(val name: String?, val age: Int) : Parcelable {
    constructor(parcel: Parcel) : this(
        parcel.readString(),
        parcel.readInt()
    )

    override fun describeContents(): Int {
        return 0
    }

    override fun writeToParcel(dest: Parcel?, flags: Int) {
        dest?.writeString(name)
        dest?.writeInt(age)
    }

    companion object CREATOR : Parcelable.Creator<Student> {
        override fun createFromParcel(parcel: Parcel): Student {
            return Student(parcel)
        }

        override fun newArray(size: Int): Array<Student?> {
            return arrayOfNulls(size)
        }
    }
}

 

코틀린을 사용한다면 @Parcelize 어노테이션을 사용하면 추가 함수 구현 없이 사용 가능하다. 몇몇 클래스에서는 어노테이션이 동작하지 않는 경우도 있는데 그럴때만 빼면 쓸만하다.

 

@Parcelize
data class Student(val name: String?, val age: Int) : Parcelable {}

 

Performance

디바이스별로 Serializable과 Parcelable을 사용한 경우를 각각 비교한 그래프다. 그래프만 봐선 Parcelable의 성능이 확실히 뛰어난 것 같다. Real World에선 어떤 차이가 있을지 나온 자료는 아니지만 기억해두면 좋을 그래프 일 것 같다.

728x90

'개발 > 안드로이드' 카테고리의 다른 글

suspend fun  (0) 2021.07.22
Single, Maybe, Completable  (0) 2021.07.04
Serializable 과 Parcelable  (0) 2021.06.19
kotlin lateinit, lazy by  (0) 2021.06.05
Kotlin - Coroutine  (0) 2021.05.21
Android 10 스토리지 정책 대처하기  (0) 2021.05.18

kotlin lateinit, lazy by

개발/안드로이드 2021. 6. 5. 14:59 Posted by 아는 개발자

자바에서 흔히 보게 되는 NullPointerException 문제를 예방하고자 코틀린에서는 변수 선언에서부터 Nullable 변수의 선언부터 엄격하게 관리한다. 변수를 선언 할 때도 Nullable인지 아닌지를 구분해야하고 Nullable인 경우에는 변수를 호출하는 코드에서 Nullsafe 지시자를 표시해야하며 그렇지 않으면 컴파일 단계에서 에러를 발생시킨다.

 

var name: String? = null 
name = "abcd"
name?.length() // name이 여전히 null 일 가능성이 존재하므로, null safe 접근만 허용된다

var name2: String = "abcd"
name2 = null // name2가 nullable하지 않으므로 이 코드는 컴파일 오류가 발생한다

 

 

그런데 코드 상에서는 null이 될 수도 있지만 실제 동작 중에는 null이 될 소지가 없는 경우가 있다. 아래 코드처럼 전역 변수인데 실행과 동시에 초기화를 시키는 경우가 이렇다. 논리적으로는 안전한 코드임에도 불구하고 값에 접근 할 때 null safe 지시자를 표시해야하는 불편함이 생긴다. 

 

class TestActivity: Activity() {
    var name: String? = null
    override fun onCreate() {
        name = "abcd"
        print("${name?.length}")
    }
}

 

lateinit 

 

lateinit을 사용하면 변수의 값을 지정하는 작업을 뒤로 미룰 수 있다. Nullable 하지 않은 변수를 선언하면서 Assign 하는 작업을 뒤로 미루고 싶을때는 lateinit 키워드를 사용하면 가능하면 된다. 아래 코드는 name 변수 앞에 lateinit 키워드를 두고 onCreate 콜백에서 값을 바로 지정했다. 선언 당시 Non-Null String으로 선언했기 때문에 호출할 때 

 

class TestActivity: Activity() {
    lateinit var name: String
    override fun onCreate() {
        name = "abcd"
        print("${name.length}")
    }
}

 

lateinit은 mutable 변수만 가능하기 때문에 var 키워드를 가진 변수에서만 사용이 가능하다. 실행 중에 값을 변경할 필요가 있는 경우 유용하다. 그리고 만약 값을 assign하지 않고 변수 값을 호출하는 경우에는 Kotlin 언어 상에서 에러를 발생시킨다.

 

by lazy 

 

by lazy 키워드는 lateinit과 비슷하게 값을 지정하는 작업을 미루는 작업인데 assign 되는 시점이 변수를 호출하는 시점이다. 아래 코드를 보면 name 변수 선언에 by lazy 키워드가 붙고 내부 브래킷에 "abcd" 코드가 있다. name변수가 호출되는 시점에 "abcd"로 assign 하겠다는 의미다. 실제 코드를 동작시켜보면 name 호출 시점에 by lazy 내부 로그가 먼저 호출되는 것을 볼 수 있다.

 

class Test {
    val name : String by lazy {
        println("this is name by lazy")
        "abcd"
    }

    init {
        println("I am here")
        println(name)
    }
}

// 실행 결과
I am here
this is name by lazy
abcd

 

by lazy는 immutable 변수에서만 적용이 가능해, val 키워드 변수에만 적용이 가능하다. 변수 값을 최초에만 설정하고 변경할 필요가 없는 경우 사용하면 유용하다. 변수 선언시에 값을 assign할 순 없지만 다른 변수들을 조합해 값을 설정하고 싶을 때 사용하면 유용하다.

728x90

'개발 > 안드로이드' 카테고리의 다른 글

Single, Maybe, Completable  (0) 2021.07.04
Serializable 과 Parcelable  (0) 2021.06.19
kotlin lateinit, lazy by  (0) 2021.06.05
Kotlin - Coroutine  (0) 2021.05.21
Android 10 스토리지 정책 대처하기  (0) 2021.05.18
다음 페이지가 살짝 보이는 ViewPager2 만들기  (0) 2021.05.13

Kotlin - Coroutine

개발/안드로이드 2021. 5. 21. 20:00 Posted by 아는 개발자

Coroutine을 공부할 때 당장 실행되는 코드를 짜려고 launch, async 함수부터 먼저 써보게 되는데(과거의 나) 이것보단 Coroutine을 이루는 구조가 무엇인지를 먼저 공부하고 유틸리티 함수를 사용하면 훨씬 이해하기가 쉽다.  Coroutine을 이루는 구조는 크게 CoroutineScope과 CoroutineContext다. 아래 그림으로 보면 CoroutineScope이 CoroutineContext를 포함하는 관계다.

 

 

1. CoroutineScope

 

CoroutineScope은 Coroutine이 활동할 수 있는 범위를 말한다. 예를 들어 Coroutine이 ViewModel의 생성주기 내에서만 동작하게 할 수 있고 Activity Lifecyle 생명주기를 따라서 동작하게 할 수 있는데 CoroutineScope은 Coroutine의 활동범위를 말한다. 이 속성을 잘 이용하면 Component의 생성주기에 맞춰 Coroutine 작업을 자동으로 취소할 수 있어서 유용하다. Kotlin에서는 안드로이드에서 사용할 수 있도록 몇가지 CoroutineScope을 미리 만들어뒀다. 

 

  • GlobalScope: 앱 프로세스의 생명주기를 따라감. 
  • MainScope: UI 관련 작업을 처리하는 용도.
  • ViewmodelScope: ViewModel의 생성주기를 따라감.
  • LifecycleScope: Activity, Fragment의 생명주기를 따라감. 생명주기별로 콜백이 다르다.
MainScope().launch {}

GlobalScope.launch {}

 

CoroutineScope 인터페이스를 구현해서 커스텀한 CoroutineScope을 만들수도 있긴 한데 공식 문서에서 이 방법은 추천하진 않고 있다.

 

2. CoroutineContext 

 

CoroutineContext는 Coroutine을 이루는 정보다. Coroutine 이름, Job, Dispatcher, ExceptionHandler 가 이에 해당한다. Dispatcher는 Coroutine이 실행될 쓰레드 풀을 의미한다. 대표적으로 Main, IO 쓰레드 풀이 있어서 UI 작업의 경우에는 Main, 디스크 작업에는 IO 쓰레드를 사용하도록 지정 할 수 있다. Executors 라이브러리를 이용해 커스텀으로 만든 쓰레드 풀에도 지정이 가능하다. 아래 코드는 우선순위가 높은 쓰레드 풀에서 동작하는 CoroutineScope을 만든 예다.

 

val customExecutor: Executor = Executors.newCachedThreadPool { r ->
    Thread(r, "CustomThread").apply {
        priority = Thread.MIN_PRIORITY
    }
}
val customDispatcher = object : CoroutineDispatcher() {
    override fun dispatch(context: CoroutineContext, block: Runnable) {
        customExecutor.execute(block)
    }
}

CoroutineScope(customDispatcher).launch {

}

 

ExceptionHandler는 Coroutine 내의 코드 실행중 발생하는 Exception을 처리할 수 있는 Handler다. 현재 Scope 별로 Exception Handler를 다르게 둘 수 있기 때문에 이것도 잘 써먹으면 유용하다. 

 

val handler = CoroutineExceptionHandler { context, th->
    println("$context ${th.toString()} ")
}

GlobalScope.launch(handler) {
    val async1 = async(){ 1 }
}

 

3. Utility 함수 

 

3.1 launch

 

launch 함수는 CoroutineScope내에서 실행되며, 현재 쓰레드를 막지 않고(blocking) 동작할 수 있는 새로운 Coroutine Job을 생성한다. 병렬로 수행되기 때문에 여러가지 작업을 동시에 수행할 때 쓰면 좋다. 아래 코드는 GlobalScope 내에서 두개의 Coroutine Job을 생성한 코드다. 앞에 코드에 300ms 의 딜레이를 줬다. 그 결과 scope2가 먼저 프린트 되고, scope1은 그 이후에 프린트 된다.

 

GlobalScope.launch {
    launch {
        delay(300)
        println("scope1")
    }
    launch {
        println("scope2")
    }
}

2021-05-21 16:14:17.677 I/System.out: scope2
2021-05-21 16:14:17.978 I/System.out: scope1

 

Job 내부 함수인 join() 은 동시성을 제어할 수 있는 함수다. 현재 Coroutine의 실행이 종료되지 않을 때까지 다음 코드를 실행하지 않는다. 순서를 관리할 때는 이 함수를 쓰면 된다. 그리고 cancel() 처럼 취소할 수 있는 함수도 있다. 이건 실제로 사용하다 보면 어떻게 써야하는지 감이 온다.

 

3.2 async 

 

async 함수는 launch 와 거의 동일하고 결과 값을 받을 수 있다는 점이 추가 됐다. 아래 코드의 두 async Job은 각각 1, 2를 리턴하는 CoroutineScope이다. async 내부 await() 함수는 여기서 실행된 결과 값을 받아오게 된다. 여기서 주의깊게 볼 부분은 각각에 delay를 300ms, 100ms 씩 줬는데도 start 로그로부터 결과 값까지 걸린 시간은 둘의 합인 400ms가 아니라 가장 긴 delay인 300ms라는 점이다. 두 Job을 병렬로 처리했기 때문에 가장 delay를 오래 잡는 Job의 시간만큼 소요된다.

 

GlobalScope.launch {
    println("start")
    val a = async {
        delay(300)
        1
    }
    val b = async {
        delay(100)
        2
    }

    println("a + b = ${a.await() + b.await()}")
}

2021-05-21 16:22:19.885 I/System.out: start
2021-05-21 16:22:20.190 I/System.out: a + b = 3

 

3.3 withContext 

 

동일한 CoroutineScope 내에서도 종종 Coroutine Context를 바꿔야 할 일이 생긴다. 예를 들면 I/O 작업을 수행 중에도 중간중간 화면 UI를 업데이트 해야하는 경우 Dispatcher를 바꿔 코드에 적용하는 쓰레드를 변경해야한다. 이럴때 쓰면 유용한 함수가 withContext다. 아래 코드는 I/O 스레드 풀에서 "abc", "def"라는 문자열을 받아오고 두 문자를 합해서 text라는 변수를 만들었다. UI 에 적용하려면 withContext를 이용해 임시로 Main함수로 바꿔주어 UI 컴포넌트에 접근 할 수 있다. 참고로 withContext는 내부적으로 async{}.await()로 구현돼 있어 내부 코드가 모두 실행된 다음에 다음 코드로 넘어가게된다. 

 

CoroutineScope(Dispatchers.IO).launch {
    val a = async {
        "abc"
    }
    val b = async {
        "def"
    }
    
    val text = a.await() + b.await()
    
    withContext(Dispatchers.Main) {
        textView.text = text
    }
}
728x90

'개발 > 안드로이드' 카테고리의 다른 글

Serializable 과 Parcelable  (0) 2021.06.19
kotlin lateinit, lazy by  (0) 2021.06.05
Kotlin - Coroutine  (0) 2021.05.21
Android 10 스토리지 정책 대처하기  (0) 2021.05.18
다음 페이지가 살짝 보이는 ViewPager2 만들기  (0) 2021.05.13
움직이는 TextView  (0) 2021.05.11

Android 10 스토리지 정책 대처하기

개발/안드로이드 2021. 5. 18. 20:40 Posted by 아는 개발자

targetSdkVersion 을 30으로 올리면 파일 절대 경로를 사용해서 접근 할 수 없기 때문에 개발자들은 지금부터 슬슬 절대 경로를 사용해서 접근하는 코드를 변경해야한다. 이번 포스트에서는 안드로이드 새로운 스토리지 정책을 적용한 과정을 다뤄본다.

 

1. 절대 경로 대신 Uri 를 사용하도록 변경

 

기존에는 ContentResolver 클래스를 이용해 파일을 읽어올 때 DATA 칼럼을 이용해서 파일의 절대 경로를 읽어올 수 있었다. 그런데 DATA 컬럼은 Android 10부터 Deprecated가 됐고, targetSdkVersion 30으로 올리면 DATA 칼럼으로 얻을 수 있는 절대 경로로 파일이 접근이 되지 않는다. 

 

private suspend fun loadVideoContent(): Cursor? = coroutineScope {
    val where = MediaStore.Video.VideoColumns.SIZE + " > " + 0
    val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
    val projections = listOf(
        MediaStore.Video.Media._ID,
        MediaStore.Video.Media.DATA, // Deprecated됨
        MediaStore.Video.Media.DISPLAY_NAME
    ).toTypedArray()

    return@coroutineScope requireActivity().contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, projections, where, null, sortOrder)
}

 

 

이제는 우리에게 익숙한 절대 경로 대신 Uri를 이용한 상대 경로를 사용해야한다. Uri는 content:// 로 시작하는 문자열인데, _ID 칼럼에서 얻어온 값과 ContentUri 클래스를 이용해서 얻어올 수 있다. 이 값도 파일을 찾는 경로로 사용되며 현재 Glide, MediaMetadataRetriever, Exoplayer처럼 유명한 안드로이드 라이브러리들은 Uri를 통해서도 파일을 불러올 수 있게끔 업데이트가 된 상태라 호환성은 크게 걱정하지 않아도 된다. 절대경로와 다른점은 실제 파일의 경로를 보여주지 않아 플랫폼 보안적인 요소가 강화된다. 반대로 개발자의 피로도는 악화되고.

 

val uriCol = cursor.getColumnIndex(MediaStore.Video.Media._ID)

do {
    mediaItems.add(
        MediaItem(
            ContentUris.withAppendedId(
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
                cursor.getLong(uriCol)
            ),

 

2. File 클래스를 선언해야하는 경우 

 

문제는 File 클래스를 선언해야하는 경우다. 오래된 오픈소스거나 Uri를 고려하지 않은 모듈인 경우엔 절대 경로가 필요한 File 클래스를 사용해야하는 경우가 종종 있다. 그런데 앞서 언급했듯이 Uri는 상대 경로다. Uri를 통해 File 클래스로 바꿀수 있긴 한데 이건 file 스키마를 가진 Uri인 경우에만 그렇다. Uri 클래스에 Kotlin에 확장 코드로 toFile() 함수가 있긴 한데 ContentUris로 얻어온 Uri 클래스에 쓰면 요런 에러가 뜬다.

 

 

이럴 때는 절대 경로를 읽을 수 있는 형태로 꼼수가 필요하다. Android 10부터 스토리지를 절대 경로로 접근하는 것은 안되지만 앱 전용 캐시 영역은 여전히 절대 경로로 접근 할 수 있다. 스토리지에 있는 파일을 캐시로 복사하면 복사한 파일의 절대 경로로 파일 클래스를 선언해줄 수 있다. copy 작업이 딜레이도 있고 불필요하게 캐시영역 써야해 완벽한 방법은 아니다. 하지만 라이브러리에서 Uri를 지원하기 전까지는 써먹을 수 있을 것 같다. 더 좋은 방법이 있다면 공유해주시면 좋겠다. 나는 이것 말고는 딱히 방법을 못찾겠다...

 

val dir = File(context.cacheDir.path + File.separator + effectFolderName)
val filePath = context.cacheDir.path + File.separator + effectFolderName + File.separator + filename
val file = File(filePath)
val inputStream = getApplication<App>().contentResolver.openInputStream(uri)

try {
    FileUtils.copyToFile(inputStream, it) // org.apache.commons.io 를 사용
} catch (e: IOException ) {
    e.printStackTrace()
}

 

3. 미디어 파일을 추가하는 경우

 

앱에서 이미지나 동영상을 다운받는 경우 예전에는 Environment.getExternalStorageDirectory().path 코드를 이용해서 직접 원하는 경로에 파일을 생성해서 추가할 수 있었으나 Android 10 부터는 ContentResolver를 이용해 Uri로 파일을 추가해야한다. 아래 코드는 이미지 파일을 저장소에 추가하는 코드다. ContentValues 값을 설정해 임의의 이미지 파일을 만든 후 insert 함수에서 생성된 Uri 변수로 FileOutputStream을 만들고 I/O 라이브러리를 이용해 기존 파일과 복사하는 작업이다. 관계형 데이터베이스에 새로운 행을 추가하고 값을 업데이트한다고 보면 쉬울 것 같다. 실제로 ContentResolver는 관계형 데이터베이스 쿼리랑 상당부분 흡사하다.

 

val collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

val contentvalues = ContentValues().also {
    it.put(MediaStore.MediaColumns.RELATIVE_PATH, "Images")
    it.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
    it.put(MediaStore.MediaColumns.IS_PENDING, true)
}

val uri = context.contentResolver.insert(collection, contentvalues)
val fos = context.contentResolver.openOutputStream(uri!!, "w")

try {
    FileUtils.copyFile(sourceFile, fos)
} catch (e: IOException) {
    return false
}
values.put(MediaStore.MediaColumns.IS_PENDING, false)
context.contentResolver.update(uri, values, null, null)

 

728x90

아래 그림처럼 ViewPager 형태인데 다음 페이지가 살짝 보이는 UI를 만드는 경우가 종종 있다. 이번 포스트에서는 ViewPager2를 이용해 이 화면을 만드는 방법을 다뤄보려고 한다.

 

val currentVisibleItemPx = DimensionUtils.dp2px(requireContext(), 40f).toInt()

margin_pager.addItemDecoration(object: RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        outRect.right = currentVisibleItemPx
        outRect.left = currentVisibleItemPx
    }
})

ViewPager에서 inflate 된 페이지는 부모의 width를 따라가게 되므로 우선 각 페이지가 전체 영역을 잡지 않게 여백을 만들어둔다. 현재 보여진 위치로부터 양 옆에 margin을 추가한다. 그러면 아래 그림과 같은 상태가 된다.

 

 

val nextVisibleItemPx = DimensionUtils.dp2px(requireContext(), 20f).toInt()
val pageTranslationX = nextVisibleItemPx + currentVisibleItemPx

margin_pager.offscreenPageLimit = 1

margin_pager.setPageTransformer { page, position ->
    page.translationX = -pageTranslationX * ( position)
}

 

다음은 이전 페이지와 다음 페이지에 이동 효과를 줘야한다. 먼저 첫번째는 offscreenPageLimit 값을 설정는데 이 속성은 ViewPager2가 스크린에 현재 페이지로부터 얼만큼 떨어져 있는 페이지를 미리 생성 할 것인지 설정하는 함수다. offscreenPageLimit 값이 1이고, 5번 페이지가 현재 위치라면 ViewPager2는 4, 6번 페이지도 미리 생성 해둔다.

 

그 다음은 setPageTransformer 함수를 사용하는 것이다. 이 콜백은 현재 포커싱된 page 뷰 객체를 받을 수 있고 각 page 별로 포커싱 된 페이지로부터 얼마만큼 떨어져 있는지 비율 정보를 position으로 받을 수 있다. 아래 그림에선 현재 페이지가 1번에 포커싱돼 있어서 이전 페이지인 0번 페이지는 -1f만큼 떨어져 있게 되고, 다음 페이지인 2번 페이지는 1f만큼 떨어져 있게 된다. 

 

 

이 정보 값을 이용하면 0번과 2번 페이지를 필요한 만큼 이동시킬 수 있다. translationX 값을 변경하면 0번과 2번 페이지가 움직여서 미리보기 형태로 볼 수 있게 된다.

 

728x90

움직이는 TextView

개발/안드로이드 2021. 5. 11. 19:57 Posted by 아는 개발자

종종 화면내에서 움직이는 TextView를 만들어야 할 때가 있다.

 

이렇게 직선형태로 움직이는 애니메이션의 경우 TranslateAnimation 클래스를 이용해서 쉽게 구현이 가능하다. 아래 코드는 새로운 TextView를 만들고 layout에 추가한 다음 애니메이션을 실행한 코드다. 주목할 부분은 TranslateAnimation 코드다.

 

CoroutineScope(Dispatchers.Main).launch {
            val movingText = TextView(requireContext()).apply {
                this.text = "움직이는 텍스트"
                this.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {}
                this.visibility = View.INVISIBLE
                this.setTextColor(0xff141414.toInt())
            }

            danmu_layout.addView(movingText)

            movingText.post {
                val animation = TranslateAnimation(requireView().width.toFloat(), -(movingText.width.toFloat()), 0f, 0f)
                animation.duration = 3000
                animation.repeatCount = Animation.INFINITE
                animation.setAnimationListener(object: Animation.AnimationListener {
                    override fun onAnimationStart(animation: Animation?) {
                        movingText.visibility = View.VISIBLE
                    }

                    override fun onAnimationEnd(animation: Animation?) {}
                    override fun onAnimationRepeat(animation: Animation?) {}
                })
                movingText.startAnimation(animation)
            }
        }

 

TranslateAnimation 생성자 인자에서 받는 값은 fromXDelta, toXDelta, fromYDelta, toYDelta다. xml 파일로 애니메이션을 작성할 때는 퍼센테이지 값을 넣을 수 있는데, TranslateAnimation 클래스를 사용하면 픽셀 값으로 입력해야한다. 각각이 의미하는 바를 보자. 

 

public TranslateAnimation(float fromXDelta, float toXDelta, float fromYDelta, float toYDelta) {

 

fromXDelta는 현재 위치로부터 Delta만큼 x축 방향으로 이동한 지점에서 애니메이션을 시작한다. 현재 위치부터 시작하고 싶다면 0을, 다른 위치로 변경하고 싶다면 특정 값을 설정하면 된다. + 값은 오른쪽으로 이동하고 - 값은 왼쪽으로 이동한다. 같은 원리로 toXDelta는 현 위치에서 x축 방향으로 이동한 지점에서 애니메이션을 종료한다. 왼쪽으로 이동한 지점에서 종료하고 싶다면 - 값을, 오른쪽으로 이동한 값에서 종료하고 싶으면 + 값을 넣으면 된다. 앞서 소개한 코드에선 fromXDelta에선 부모 뷰의 width만큼 움직여서 화면 밖에서 시작하고, toXDelta는 현재위치에서 텍스트의 width만큼 왼쪽으로 움직이므로 화면 밖으로 사라지는 애니메이션을 만들 수 있었다. 절대적인 좌표가 아니라 아니라 현 위치로부터 상대적인 거리로 값을 입력해야 한다는 점을 주의하자. y축에서도 동일한 원리를 적용할 수 있다.

 

728x90

저장공간으로부터 파일을 읽어오는 앱을 출시한 개발자의 경우 올해 4월 15일부터 플레이스토어에서 아래 알림을 보게 될 확률이 높다..!

 

평소 같으면 이런 알림은 그냥 넘겨버렸는데 이번 공지에는 "허용된 용도 외에 모든 파일 액세스 권한에 액세스를 요청하는 앱은 Google Play에서 삭제되고, 업데이트를 게시할 수 없게 됩니다" 라는 무시무시한 경고문이 들어 있어 개발자들을 당황스럽고 잔뜩 쫄아버리게 만든다. 아니 갑자기 스토어에서 내려버리겠다고 으름장을 내다니. 그것도 5/5일부터 적용할 예정인 정책을 4/15일에 알려주는건 어처구니가 없다. 한 달 전도 아니고 3주 남짓한 시간 정도에 이걸 어떻게 모두 정리하냐는 말이다. 그래서 평소 같으면 그냥 지나쳐버린 자세히 알아보기 버튼을 클릭했다.

 

다행히 정책이 적용되는 버전은 Android 11 수준을 타겟팅하는 앱 대상이다. 만약 현재 안드로이드 앱의 타겟 SDK가 29이하라면 아직까진 안심해도 좋다. 그런데 일찍 Android 11로 업그레이드 완료했다면 모든 권한을 요청하고 있다면

 

1. 지금부터 불필요하게 권한을 요청하는 코드를 없애던지, 

 

2. 모든 권한을 요청해야하는 이유를 양식으로 제출하던지,

 

3. SDK 버전을 29로 다시 내리던지 

 

하는 선택을 해야한다. 대부분 앱이 아직 SDK 29버전을 따르고 있을 것 같아서 큰 문제는 없을 것 같은데 부지런히 SDK 30으로 업그레이드한 개발자들은 일찍 일어난 새가 모이를 먼저 먹지 못하고 구글의 깡패 같은 정책을 먼저 얻어 맞는 꼴이니 억울함이 상당할 것 같다. 공지에 따르면 코로나 19 관련 고려사항이라고 하는데 코로나랑 저장공간 권한이 무슨 관련이 있는건지.. 그러면 SDK 30 이하 버전들도 공통적으로 적용돼야하는거 아닌가. 코로나가 SDK 30을 타겟팅한 앱만 좋아하는 것도 아닌데.

 

코로나를 핑계로 둔건지 모르겠지만 아무튼 구글은 앞으로 저장 공간에 관한 권한 관리를 빡세게 할 것을 암시하고 있다. Android 10 때 야심차게 범위 저장 공간(Scoped Storage)를 내놨다가 쓰기 어렵다는 베타 개발자들의 아우성을 듣고 requestLegacyExternalStorage 옵션을 열어 줬는데 이제는 개발자 너희들도 슬슬 준비해야 할 때라고 눈치를 주는 느낌(?) 이다. 당시엔 개발자 커뮤니티의 반대가 워낙 심각해서 구글이 이 정책을 포기할 것 같았으나 포기는 커녕 플레이스토어에서 내려버릴 거라고 엄포를 내놓고 있으니 어쩔수가 없다. 그러면 처음부터 정책을 잘 좀 만들던가!

 

나도 Android 10으로 올리면서 MediaStore를 이용해 파일의 경로를 재설정 할 수 있는 연동작업을 진행했는데 너무 빡세서 연동중에 포기하고 requestLegacyExternalStorage 옵션을 키고 기억속에 묻어 뒀었다. 그때 기존 코드를 바꾸는 과정이 고통스러웠던 기억이 아직도 생생한데. 이제는 슬슬 작업을 진행해야 할 것 같다. 나중에는 연동작업에 대해서 다뤄보려고 한다.

728x90

Thread, Runnable, Callable, ThreadPool

개발/안드로이드 2021. 4. 23. 17:29 Posted by 아는 개발자

1. Thread

 

Thread 클래스는 Java 언어에서 비동기 작업시 대표적으로 사용하는 클래스다. 코틀린과 람다를 이용하면 아래와 같이 간단하게 비동기 작업 코드를 짤 수 있어서 짧은 디코딩 작업이나, 연산처리를 할 때 주로 사용된다. 

 

fun testThread() {
    val thread1 = Thread {
        Thread.sleep(1000)
        Log.d(this.toString(), "this is test thread1")
    }

    val thread2 = Thread {
        Log.d(this.toString(), "this is test thread2")
    }

    thread1.start()
    thread2.start()
}

 

그런데 Thread를 생성하는 작업은 안드로이드 밑단 운영체제에서 pthread를 생성하게 되는 작업이므로 추가적인 메모리를 할당받게 된다. 그래서 Thread를 많이 생성할수록  더 많은 메모리 공간을 차지하게되기 때문에 무한정 만들 수 없다. 

 

2. Runnable 

 

앞서 언급한 Thread를 간소화한 형태다. Thread는 클래스인 반면 Runnable은 인터페이스라서 다른 클래스를 상속하고 있는 클래스에 Runnable을 추가할 수 있고 상속 형태 없이 익명 클래스 형태로 바로 써도 된다. 실행은 run() 클래스를 호출해서 가능하다. 

 

fun testRunnable() {
    val runnable = Runnable { Log.d(this.toString(), "test runnable") }

    runnable.run()
}

 

3. Callable 

 

Runnable과 비슷하지만 Callable은 비동기 작업에서 리턴값도 줄 수 있고 결과 값을 연산하지 못하면 Exception을 발생시키기도 한다. 리턴값을 제공해야하는 비동기작업이면 Runnable 말고 Callable을 쓰면 된다. 아래 코드는 Callable에서 정수 값들을 리턴하도록 하고 각각의 합을 로그로 찍은 것이다

 

fun testCallable() {
    val callableOne = Callable { 1 }
    val callableTwo = Callable { 2 }
    val callableLog = Callable { Log.d(this.toString(), "this is callable log") }

    try {
        val merged = callableOne.call() + callableTwo.call()
        callableLog.call()

        Log.d(this.toString(), "this is callable merged: $merged")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

 

4. ThreadPool

 

앞서 설명한 Thread, Runnable, Callable 모두 쓰레드 계열이라서 개별로 메모리를 쓰게돼, 무한정 만들면 메모리 낭비가 발생한다. 그래서 자바에서는 ThreadPool이란 것으로 쓰레드를 관리할 수 있도록 했다.  ThreadPool 관리 객체는 Executors 클래스를 통해서 생성하는데 이 클래스 검색해보면 내부의 함수를 통해 다양한 방식으로 Thread를 관리할 수 있는 것을 확인 할 수 있다. 지면상 모두 설명할 수 없고, 아래 코드에서 쓰는 newFixedThreadPool 함수만 설명하면 쓰레드를 최대 4개만 만들 수 있게 하고 나머지 작업은 재사용하겠다는 듯이다. 새로운 작업을 추가해도 쓰레드는 4개 이상 생기지 않으며 나머지는 재사용되기 때문에 메모리 낭비에 대한 부담 없이 새로운 작업을 추가할 수 있다.

 

작업은 Callable, Runnable의 형태로 추가 가능하며 submit 함수로 추가한다. 이때 리턴 값은 Future 클래스로 받게되고, get() 함수를 통해 결과 값을 얻을 수 있다.

 

fun testExecutorService() {
    val cachedPool = Executors.newFixedThreadPool(4)
    val futureOne = cachedPool.submit(Callable { 1 })
    val futureTwo = cachedPool.submit(Callable { 2 })
    val printLog = cachedPool.submit(Runnable {
        Log.d(this.toString(), "something...") })

    try {
        val merged = futureOne.get() + futureTwo.get()
        printLog.get()
        Log.d(this.toString(), "merged: $merged")

    } catch (e: Exception) {
        e.printStackTrace()
    }
}

 

728x90

jitpack 이란

개발/안드로이드 2021. 3. 28. 09:54 Posted by 아는 개발자

안드로이드 개발중 open source 라이브러리를 임포트 할 때 아래 코드처럼 가이드에 jitpack 주소를 추가하라는 문구를 보는 경우가 종종 있다.

 

allprojects {
        repositories {
            jcenter()
            maven { url "https://jitpack.io" }
        }
}

 

여기서 추가한 jitpack 주소는 추가하려는 오픈소스 라이브러리를 저장하고 있는 저장소다. jitpack은 안드로이드, JVM 형태의 오픈소스 라이브러리 배포 플랫폼이다. 추가하려는 오픈소스 라이브러리 뿐만 아니라 깃허브 프로젝트에 올라온 오픈소스 프로젝트들을 저장하고 있으며 프로젝트에서 사용할 수 있게 jar, aar 형태로 빌드한 상태로 받을 수 있다. 깃헙으로 오픈소스를 배포하고 싶은 개발자 입장에선 쉬운 배포 툴이고 깃허브뿐만 아니라 BitBucket, GitLab, Gitee, Azure 같은 저장소에서 올린 오픈소스 프로젝트도 연동이 가능하니 개발한 라이브러리르 전세계 유저한테 배포하고 싶을땐 jitpack이 가장 좋은 옵션이 될 것 같다.

728x90

네트워크 디버깅으로 Stetho 라이브러리와 chrome://inspect 를 이용하곤 하는데, 안드로이드 스튜디오의 Profiler를 사용해도 동일하게 네트워크 디버깅을 할 수 있다. Stetho를 오래 사용하는 경우에 안드로이드 스튜디오랑 연결된 디바이스가 끊기는 문제가 있었는데 이 방식을 이용하면 끊길 염려 없이 사용할 때 더 간편하다. 이번 포스트에서는 Profiler를 이용한 네트워크 디버깅 방법을 간단히 소개한다.

 

1. Profiler 실행 

 

View -> Tool Windows -> Profiler 로 Profiler를 실행한다. 

 

 

2. 프로파일링할 프로세스 선택 

 

현재 연결중인 디바이스에서 디버깅할 앱 프로세스를 세션으로 추가한다. 당연한 얘기지만 앱은 debug 모드로 빌드해야 프로파일링 할 수 있다. 선택을 하면 프로세스의 CPU, Memory, Network, Energy 사용량을 시간 순서로 볼 수 있다.

 

 

 

3. 네트워크 프로파일링 선택 

 

그래프에서 네트워크를 선택하고 앱에서 네트워크 요청을 보내보면 아래 그림처럼 파란 직선 그려진 트래픽을 볼 수 있다. 마우스를 이용해 파란색 직선 영역을 블록처리해보면 해당 구간에서 주고 받은 네트워크 요청 목록을 볼 수 있다. 

 

 

4. 네트워크 디버깅 

 

디버깅하려는 네트워크 요청을 클릭해보면 오른쪽 탭에 상세 요청과 응답을 볼 수 있다. Body가 Json 형태인 경우 깔끔하게 그려준다. chrome://inspect에 비해 좋은 점은 Body에서 에디팅이 쉽다. chrome의 경우에는 복사를 하는 경우 디버깅 화면이 꺼지는 버그가 있었는데 Profiler를 사용하면 이런 버그가 없이 일반 코드에서 에디팅 할 때랑 똑같다.

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

Thread, Runnable, Callable, ThreadPool  (0) 2021.04.23
jitpack 이란  (0) 2021.03.28
안드로이드 스튜디오를 이용한 네트워크 디버깅  (0) 2021.03.14
RoundedFrameLayout  (0) 2021.03.03
겹치는 recyclerview 만들기  (0) 2021.02.15
android - Hilt 사용기  (0) 2021.01.15

RoundedFrameLayout

개발/안드로이드 2021. 3. 3. 13:26 Posted by 아는 개발자

레딧의 투데이 피드탭

디자이너와 협업하다보면 위 그림처럼 이미지의 꼭지점 부분에 radius를 넣어야하는 경우가 종종 생긴다. 아이콘으로 넣는 이미지의 경우에는 디자이너가 직접 아이콘의 radius를 먹일 수 있는데 뉴스피드처럼 외부에서 받아오는 이미지의 경우에는 매번 작업을 할 수 없어 코딩으로 처리해야한다. 이럴 경우 RoundedFrameLayout 라이브러리를 사용하면 쉽게 처리가 가능하다.

 

1. 라이브러리 설치 

 

build.gradle에 추가해서 적용한다.

 

dependencies {
    // RoundedFrameLayout
    compile 'com.github.QuarkWorks:RoundedFrameLayout-Android:0.3.7'
}

 

2. 적용 

 

RoundedFrameLayout은 이미지가 적용되는 ImageView의 부모로 설정한다. 뷰의 속성 값으로 cornerRadius가 있는데 이 값을 이용해서 얼마나 깎을 것인지 적용 할 수 있다. ImageView는 부모 레이아웃이 변경됐으므로 자동으로 적용되게된다.

 

<com.quarkworks.roundedframelayout.RoundedFrameLayout
    android:id="@+id/layout_rounded_image"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:cornerRadiusTopLeft="10dp"
    app:cornerRadiusTopRight="10dp"
    app:cornerRadiusBottomLeft="10dp"
    app:cornerRadiusBottomRight="10dp">
    <ImageView
        android:id="@+id/layout_rounded_image_iv"
        android:background="#0c000000"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="centerCrop"/>
</com.quarkworks.roundedframelayout.RoundedFrameLayout>
728x90

'개발 > 안드로이드' 카테고리의 다른 글

jitpack 이란  (0) 2021.03.28
안드로이드 스튜디오를 이용한 네트워크 디버깅  (0) 2021.03.14
RoundedFrameLayout  (0) 2021.03.03
겹치는 recyclerview 만들기  (0) 2021.02.15
android - Hilt 사용기  (0) 2021.01.15
item decoration  (0) 2020.12.06

겹치는 recyclerview 만들기

개발/안드로이드 2021. 2. 15. 17:44 Posted by 아는 개발자

서비스 개발 하다 보면 위 그림처럼 recyclerview인데 아이템을 겹치는 형태로 만들어야 할 때가 있다. 먼저 쉽게 생각해 볼 수 있는 방법은 ItemDecoration을 이용해 item1을 제외한 item2, item3의 left 오프셋을 왼쪽으로 당겨주는 방법이 있다.

 

rv.addItemDecoration(object: RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val position = parent.getChildAdapterPosition(view)
        if (position != 0) outRect.left = DimensionUtils.dp2px(requireContext(), 10f).toInt() * -1  
    }
})

 

그런데 이렇게 만들면 예상했던 것과 다르게 뒤에 있는 아이템이 앞에 있던 아이템 위로 올라가게 된다. 뒤에 있는 아이템을 우선순위를 높게 쳐서 발생하는 에러다.

 

 

처음에 계획했던 대로 만들려면 recyclerview 에 약간 트릭을 추가해야한다. 사용한 LinearLayoutManager에서 reverseLayout과 stackFronEnd 속성 값을 true로 설정한다. reverLayout을 true로 두면 아이템을 RTL에 맞춰서 오른쪽으로 쌓는 것이고, stackFronEnd는 recyclerview 영역의 끝부분부터 채우는 것이다. item을 역순으로 출력할 것이므로, 맨 앞에 있는 것은 맨 뒤로 가기 때문에 offset 설정 함수도 끝 부분이 이동하도록 바꿔준다.

 

rv.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false).apply {
    reverseLayout = true
    stackFromEnd = true
}

rv.addItemDecoration(object: RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val position = parent.getChildAdapterPosition(view)
        if (position != (adapter?.itemCount?: 0) - 1) {
            outRect.left = DimensionUtils.dp2px(context, 10f).toInt() * -1
        }
    }
})

 

 

위 코드로 설정하면 아래와 같은 그림이 나온다. 예상했던 그림이긴 한데, item 순서가 역순이다.  해결방법은 간단하다. rv의 adapter에 item을 넣을 때 역순으로 넣으면 된다.

 

adapter?.submitItems(it.reversed())

 

결과 이렇게 겹치는 recyclerview 아이템을 볼 수 있다.

 

728x90

android - Hilt 사용기

개발/안드로이드 2021. 1. 15. 14:29 Posted by 아는 개발자

예전에 쓴 Hilt 포스트에선 기존에 사용중인 프로젝트에 Hilt를 쉽게 적용할 수 없어 아쉽다는 점을 다루었다. 그래서 최근에 소소하게 시작한 사이드프로젝트에선 처음부터 Hilt를 도입해서 사용해봤다. 확실히 Dagger에 비해 자유롭고 사용하기가 간편했다. 이번 포스트에서는 어떤점이 좋았는지를 다뤄보고자 한다. 

 

1. private val 변수 형태로 주입 가능.

 

Dagger로 의존성을 주입할 때는 @Inject 어노테이션과 뒤에 lateinit var 을 붙여줘야했다. 그런데 앞으로 바뀌지 않을 변수에 var 형태로 선언하는게 여간 찝찝한게 아니었다. 다행히 Hilt에서는 이런 찝찝함을 해결했다. 생성자의 인자로 추가해 의존성을 주입할 수 있어 값이 변경되지 않은 val 형태로 주입이 가능하다. 아래 코드는 @ViewModelInject 어노테이션을 이용해 module에서 선언된 객체들에 바로 의존성을 주입하는 코드다. private 변수로도 주입이 가능하다.

 

class AssetEditorViewModel @ViewModelInject constructor(
    @Assisted private val savedStateHandle: SavedStateHandle,
    application: Application,
    private val assetRepository: AssetRepository,
    private val assetTypeRepository: AssetTypeRepository
): AndroidViewModel(application) {

}

@Module
@InstallIn(ApplicationComponent::class)
class DatabaseModule {
    ...

    @Singleton
    @Provides
    fun provideAssetRepository(appDatabase: AppDatabase) = AssetRepository(appDatabase.assetDao())

    @Singleton
    @Provides
    fun provideAssetTypeRepository(appDatabase: AppDatabase) = AssetTypeRepository(appDatabase.assetTypeDao())
}

 

물론 activity, fragment 처럼 생성자를 customize 할 수 없는 클래스도 있다. 이런 경우 기존과 동일하게 lateinit var를 붙인 채로 주입이 가능하다.

 

@AndroidEntryPoint
class MainFragment : BaseFragment(R.layout.fragment_main) {

    @Inject lateinit var assetRepository: AssetRepository

 

2. ViewModel 의존성 주입이 쉽다

 

Dagger에서는 ViewModel 을 공식적으로 지원해주는게 아니어서 별도의 Factory 클래스를 만들어서 주입을 해줘야 했다. 예로 Fragment를 만들면 이 Fragment Module에선 주입할 ViewModel을 팩토리 형태로 만들어줘야하고 ViewModelMap에 따로 등록도 해줘야하고 결과적으로 코드가 너무 늘어나 관리가 어렵다. Hilt에서는 ViewModel 의존성 주입을 공식적으로 지원해주기 시작했다.

 

ViewModel은 @ViewModelInject 어노테이션을 생성자 앞에 붙이고 ViewModel에서 사용하려는 의존성 주입 클래스를 선언만 하면 된다. Activity, Fragment 단에서는 코틀린 delegate 속성인 by viewModels(), by activityViewModels()를 통해 ViewModel을 받으면 평소와 동일하게 사용할 수 있다.

 

@AndroidEntryPoint
class MainActivity : BaseActivity() {
    private val mainViewModel: MainViewModel by viewModels()
}

@AndroidEntryPoint
class AssetsFragment: Fragment(R.layout.fragment_assets) {
    private val mainViewModel: MainViewModel by activityViewModels()
}

class MainViewModel @ViewModelInject constructor(
    @Assisted private val savedStateHandle: SavedStateHandle,
    application: Application,
    private val accountRepository: AccountRepository,

 

3. Module 만들고 등록 할 필요가 없다.

 

Dagger에서는 어떤 Module을 만들면 Dagger에 등록해주는 Module에다가 추가해야했다. 그래서 열심히 Module을 만들어도 추가하는 작업을 빼먹어으면 런타임시 에러가 수두룩 뜨곤 했었다. 근데 Hilt에서는 따로 추가하는 작업 없이 @InstallIn 어노테이션만 추가해주면 된다. 귀찮고 빼먹기 쉬운 코드를 확 줄일 수 있었다.

 

@Module
@InstallIn(ApplicationComponent::class)
class DatabaseModule {
    @Singleton
    @Provides
    fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(context, AppDatabase::class.java, "database")
            .build()
    }

 

이외에도 편리한 점이 더 많을텐데 사이드 프로젝트 규모가 크지 않아서 아직 다 경험하지 못한 것 같다... 앞으로 쓰다가 괜찮으면 추가로 정리해서 올려야지.

728x90

item decoration

개발/안드로이드 2020. 12. 6. 14:25 Posted by 아는 개발자

recycler view를 사용할 때 item 간의 간격을 다르게 주고 싶을 때가 있다. 예를 들어 a타입과 b타입의 아이템 사이의 간격은 상하 10dp, b타입과 c타입의 간격은 상하 5dp 이런식으로 설정하거나 더 보편적으로는 마지막 아이템인 경우에는 간격을 좀 더 띄워서 넣으려고 하는 경우가 있다. 이때 가장 빠르게 떠오르는 방법은 recyclerview의 adapter에서 position별로 margin을 주는 경우인데 이렇게 하면 안된다. recyclerview에서 자체적으로 position을 관리하기 때문에 내가 보고 있는 recyclerview에서 관리하고 있는 position이 다르다. 그래서 나는 분명히 제대로 준것 같은데 실제로 보면 다른 item에 margin이 들어간다. 이 부분이 크게 눈에 띄지 않는 부분이라 잘못 짜두고도 눈치채기가 어려워 종종 그냥 넘어가는데 나중에 디버깅해보면 item간의 간격이 내가 의도한 것과 다르게 표시된다. 그것도 아주 보기 싫게.

 

item간의 간격을 dynamic하게 조절할 때는 recycler view에서 관리하는 item decoration 라이브러리를 사용해야한다. 여기서 넘어오는 view는 recycler view에서 관리하고 있는 현재 item의 view다. 이 인자와 getChildAdapterPosition 함수를 이용해 현재 view item의 index를 찾을 수 있다. 이 정보와 outRect 인자를 활용해서 각 간격을 얼마나 줄 것인지 설정 할 수 있다.

 

recyclerview.addItemDecoration(object: RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        super.getItemOffsets(outRect, view, parent, state)
        when (parent.getChildAdapterPosition(view)) {
            0 -> {
                outRect.left = DimensionUtils.dp2px(context, 20f).toInt()
                outRect.right = DimensionUtils.dp2px(context, 10f).toInt()
            }
            listAdapter?.itemCount?: 1 - 1 -> {
                outRect.left = DimensionUtils.dp2px(context, 10f).toInt()
                outRect.right = DimensionUtils.dp2px(context, 20f).toInt()
            }
            else -> {
                outRect.left = DimensionUtils.dp2px(context, 10f).toInt()
                outRect.right = DimensionUtils.dp2px(context, 10f).toInt()
            }
        }
    }
})

 

이제 잘못짠 코드들을 하나씩 수정해야겠다..

728x90

이전 포스트에서는 카메라에서 담고 있는 프레임을 OpenGL로 그린 후 GLSurfaceView로 그려주는 작업을 했었다. 지금부터는 그려진 이미지를 비디오 파일로 만드는 작업에 대해서 분석해보고자 한다.

 

3. 미리보기 영상 인코딩하기 

 

MediaCodec을 사용한 비디오 인코딩 작업도 Renderer와 동일하게 OpenGL을 이용한 그리기 작업이 필요하다. 전반적인 구현 아이디어는 비디오 녹화용 EGL Context를 선언한 후 Renderer 클래스로부터 현재 촬영 중인 카메라의 이미지를 받아와 OpenGL로 다시 그려주고 Media Codec에서 받을 수 있는 Surface 형태로 보내는 것이다.

 

3.1 비디오 인코딩용 EGL Context 선언 

 

비디오 녹화 작업도 OpenGL 작업이 필요하므로 OpenGL 작업용 EGLContext를 만들어준다. 이때 Renderer로부터 카메라 촬영 이미지를 받아오기 위해 EGL 초기화 작업에 공유 EGL Context 정보(shared_context)를 추가한다. 

fun setVideoEncoder(videoEncoder: MediaVideoEncoder?) {
    this.videoEncoder = videoEncoder

    videoEncoder?.setEglContext(EGL14.eglGetCurrentContext(), textureId)
}

private fun createContext(shared_context: EGLContext?): EGLContext {
    val attrib_list = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
    val context =
        EGL14.eglCreateContext(mEglDisplay, mEglConfig, shared_context, attrib_list, 0)
    checkEglError("eglCreateContext")
    return context
}

 

3.2 Renderer로부터 카메라 이미지 받아오기 

 

Renderer로부터 새로운 프레임이 발생했다는 콜백을 받으면 VideoEncoder는 카메라로부터 이미지를 받아와서 새롭게 그려주게 된다. 카메라 이미지는 Renderer 클래스 내의 texture에 있으며 고유한 texture id를 EGL내에서 bind 해서 받아 올 수 있게 된다. VideoEncoder 클래스에 해당 textureId를 전달해서 VideoEncoder의 EGLDisplay에 그려준다.

fun draw(tex_id: Int, tex_matrix: FloatArray?) {
    GLES20.glUseProgram(hProgram)
    if (tex_matrix != null) GLES20.glUniformMatrix4fv(muTexMatrixLoc, 1, false, tex_matrix, 0)
    GLES20.glUniformMatrix4fv(muMVPMatrixLoc, 1, false, mMvpMatrix, 0)
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, tex_id)
    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, VERTEX_NUM)
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0)
    GLES20.glUseProgram(0)
}

 

3.3 MediaCodec으로 이미지 버퍼 전달 

 

인코딩을 위해 새롭게 그린 이미지를 MediaCodec에서 만든 Surface에 버퍼로 전달한다. OpenGL 에서 제공하는 eglSwapBuffers 함수를 사용하면 MediaCodec이 받을 수 있는 surface에 전달이 가능하다.

private fun swap(surface: EGLSurface?): Int {
    if (!EGL14.eglSwapBuffers(mEglDisplay, surface)) {
        val err = EGL14.eglGetError()
        if (DEBUG) Log.w(TAG, "swap:err=$err")
        return err
    }
    return EGL14.EGL_SUCCESS
}

 

3.4 전달 받은 정보를 인코딩 

 

MediaCodec 고유 함수를 이용해 전달받은 정보를 인코딩한다. MediaCodec 관련 코드는 생략한다. 

 

4. 마치며 

 

포스트에선 전반적인 구현 아이디어만 다루었기 때문에 보기에는 쉽지만 실제 사용된 코드는 꽤 복잡했다. google에서 짜둔 클래스 간의 상속과 인터페이스 관계를 따라가는 게 생각보다 시간이 걸렸고 아직도 생소한 OpenGL 클래스의 역할과 내부 코드를 알지 못해 문서를 찾아가느라 어려웠다.

 

그래도 고생하면서 생소했던 카메라와 OpenGL 관련 지식을 배운게 개발자로서 큰 소득이다. 스노나 틱톡의 카메라 효과 코드를 보진 못했지만 아마 위 구현 방식과 크게 차이가 나지 않을 것 같다. 여기에 OpenGL 코드를 더 확장시키면 나도 촬영 중인 화면에 여기에 필터를 변경하고 스티커도 추가해볼 수 있겠다. 현재 구현된 코드를 한층 더 업그레이드시켜봐야겠다.

 

오디오까지 같이 녹화하고 싶다면 여기 깃허브 코드를 참조하면 좋다.

 

https://github.com/saki4510t/AudioVideoRecordingSample

728x90

0. MediaRecorder의 한계

 

구글이 운영 중인 안드로이드 카메라 Sample 코드 저장소에선 Camera2Camera 2 라이브러리를 이용해 사진을 찍거나 비디오 녹화를 할 수 있는 예제가 있다. Camera 2 Video 프로젝트의 비디오 녹화 예제 코드의 경우 카메라에서 출력되는 프레임을 MediaRecorder라는 클래스를 이용해서 녹화할 수 있도록 했는데 이 방식은 후면 녹화의 경우에는 별로 문제가 없으나 전면 카메라를 이용하는 경우 미리보기에서 나온 영상이 그대로 저장되지 않고 좌우가 반전돼서 나오게 되는 문제가 있다. 대부분 카메라 어플에서 제공하는 옵션인 보이는 대로 저장 하기 기능을 사용할 수 없는 큰 문제점(?) 이 존재한다

 

보이는대로 저장 옵션을 사용할 수 없다. 그래서 촬영한후 저장한 내 모습이 아주 어색하게 저장된다

 

전면 카메라에 출력된 내 모습 그대로 저장하기 위해선 MediaRecorder 클래스 대신 대신 카메라에서 출력된 프레임을 OpenGL 그래픽 라이브러리를 이용해 렌더링 한 후 화면에 출력된 프레임을 MediaCodec을 이용해 직접 비디오 파일을 만드는 과정이 필요하다. MediaRecorder를 사용하는 코드가 워낙 간편했거니와 그래픽 라이브러리와 MediaCodec을 사용하는 작업은 대부분 개발자들에게도 생소한 OpenGL 지식이 필요하기 때문에 다소 까다롭다. 하지만 이것 말고는 전면 카메라를 반전시킬 방법은 없기 때문에 어렵더라도 직접 구현해봤다.

 

1. 오픈소스

 

다행히 구글의 비공식 저장소인 grafika에서 이미 구현한 코드가 있었다. 카메라에서 촬영중인 프레임을 안드로이드 그래픽 라이브러리에 렌더링 한 후 화면에 출력된 이미지를 MediaCodec을 이용해 MP4의 파일로 만드는, 앞서 의도한 방식을 그대로 구현한 코드였다. 그런데 3-4년 전에 작성한 코드라서 현재는 Deprecated 된 Camera 라이브러리를 사용 중이어서(현재는 Camera 2를 주로 쓰고 CameraX 알파 버전이 개발 중이다) grafika 코드를 분석하고 여기서 동작하는 모듈을 Camera 2랑도 연동이 될 수 있도록 하는 방향으로 개발했다.

 

2. Camera2와의 GLSurfaceView 연동 과정 

 

camera2 구조

grafika에서 이미 구현한 부분은 Surface, Renderer, GLSurface 간의 연동 과정이고 내가 추가적으로 넣은 부분은 Camera 2와 Renderer에서 만든 Surface를 연동한 부분뿐이다. 연동 과정과 각 클래스의 역할을 분석한 내용을 단계별로 정리했다.

 

2.1 Renderer 초기화 작업

 

GLSurfaceView는 OpenGL로 그려진 이미지를 안드로이드 UI에 노출 시켜줄 수 있는 클래스다. Renderer 클래스는 GLSurfaceView에 표시할 이미지를 OpenGL로 그리는 역할을 한다. Renderer 클래스가 GLSurfaceView의 그리는 역할을 담당할 수 있도록 setRenderer  함수를 이용해 두 클래스를 연결시켜준다. 이러면 Renderer에서 그린 OpenGL 이미지가 GLSurfaceView에 표시된다.

 

class CameraSurfaceRenderer(private val glSurfaceView: GLSurfaceView) : GLSurfaceView.Renderer,

    init {
        Matrix.setIdentityM(mvpMatrix, 0)
        glSurfaceView.setEGLContextClientVersion(2)
        glSurfaceView.setRenderer(this)
        glSurfaceView.renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
    }

 

연결 작업후 OpenGL을 이용해 그릴 수 있는 공간을 선언하는 초기 작업이 필요한데 이 작업은 Surface가 생성된 이후에 불리는 onSurfaceCreated 콜백 함수에서 담당한다. 이 함수 내에서는 OpenGL Texture를 생성하는 초기화 외부로부터 이미지 스트림을 받을 SurfaceTexture를 선언한다. SurfaceTexture는 OpenGL Texture로 이미지 스트림을 보내는 역할을 한다.

 

class CameraSurfaceRenderer(private val glSurfaceView: GLSurfaceView): GLSurfaceView.Renderer, SurfaceTexture.OnFrameAvailableListener {
    @SuppressLint("Recycle")
    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        hTex = GLDrawer2D.initTex()
        surfaceTexture = SurfaceTexture(hTex)
        surfaceTexture?.setOnFrameAvailableListener(this)

        // clear screen with yellow color so that you can see rendering rectangle
        GLES20.glClearColor(1.0f, 1.0f, 0.0f, 1.0f)

        drawer = GLDrawer2D()
        drawer.setMatrix(mvpMatrix, 0)
    }
}

 

2.2 Camera2 촬영 중인 공간 표시

 

Camera 2에서는 Surface 클래스의 형태로 카메라에서 보고 있는 이미지를 전달받을 수 있다. 앞서 Renderer에서 외부로부터 이미지를 받을 공간을 SurfaceTexture로 선언했는데 카메라의 이 클래스를 이용해 Surface를 만들면 카메라로부터 이미지를 전달받고 미리보기로 보여줄 수 있게 된다.

private fun startRecordingVideo() {
    if (cameraDevice == null) return

    try {
        closePreviewSession()

        // Set up Surface for camera preview
        val previewSurface = Surface(renderer.surfaceTexture)
        val surfaces = ArrayList<Surface>().apply {
            add(previewSurface)
        }
        previewRequestBuilder = cameraDevice!!.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
            addTarget(previewSurface)
        }

 

2.3. 카메라로 부터 받은 이미지 스트림을 OpenGL로 그리기

 

카메라에서 Renderer에서 선언한 SurfaceTexture에 이미지 스트림으로 보내주는 것이 됐으니 카메라가 보고 있는 이미지 스트림 정보를 실제로 OpenGL 코드로 그려주는 작업이 필요하다. Renderer 함수에는 두 개의 콜백 함수가 있는데 카메라에서 이미지를 전달받은 SurfaceTexture가 호출하는 onFrameAvailable() 콜백이 먼저 불린다. 이 함수에선 최신 이미지가 도착했으니 현재 Renderer와 연동된 GLSurfaceView에게 업데이트를 요청하는 함수인 requestRender() 함수를 호출한다.

 

requestRender() 호출 후엔 연달아서 onDrawFrame() 이 불리는데 여기선 SurfaceTexture로 전달받은 카메라의 프레임 정보를 OpenGL Texture에 업데이트 시킨 후 OpenGL 명령어로 화면에 그리는 작업을 한다. 이 작업이 생략되면 카메라 초기화 작업은 잘 됐음에도 불구하고 화면에는 검은 화면만 뜨게 된다. OpenGL로 그림을 그리는 코드는 생략했다.

 

override fun onFrameAvailable(surfaceTexture: SurfaceTexture?) {
    requesrUpdateTex = true
    glSurfaceView.requestRender()
}

override fun onDrawFrame(gl: GL10?) {
    if (requesrUpdateTex) {
        requesrUpdateTex = false
        surfaceTexture?.updateTexImage() // 전달 받은 카메라 이미지 프레임을 OpenGL Texture 에 업데이트
        surfaceTexture?.getTransformMatrix(stMatrix)
    }

    drawer.draw(hTex, stMatrix) // Texture 가지고 OpenGL로 그림을 그린다
    if (recording) {
        videoEncoder?.frameAvailableSoon(stMatrix, mvpMatrix)
    }
}

 

여기까지 완료하면 Camera2를 이용해서 출력 중인 화면을 OpenGL 코드로 화면에 보여주는 것까지 가능하다. 그러나 녹화를 하기 위해선 Renderer에서 출력되고 있는 프레임 정보를 MediaCodec으로 보내서 MP4 파일을 만드는 작업까지 가야 하는데 이 내용은 다음 포스트에서 다룰 예정이다.

728x90

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

 

728x90

 

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

 

728x90

 

현재 안드로이드 의존성 주입 라이브러리로는 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 어노테이션만 추가해주면 알아서 빌드가 된다. 

 

Hilt 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
728x90
  1. alpacino609 2021.03.23 14:00  댓글주소  수정/삭제  댓글쓰기

    이런게 있었네요^^글 감사합니다. 그런데 3번에 두번째 예제는 dagger가 아니라 hilt라는 말씀이죠?

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초를 기다린 다음에 화면에 업데이트 된다.

 

728x90

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

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

Hilt - Dagger를 이을 의존성 주입 라이브러리(1)  (2) 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

RxJava - combineLatest

개발/안드로이드 2020. 6. 28. 17:44 Posted by 아는 개발자

 

combineLatest 함수는 모든 Observable 소스에서 이벤트를 받은 후 마지막으로 받은 소스의 시점에서 새로운 이벤트를 만들고 싶을 때 사용한다. 아래 그림을 보면 두개의 소스를 받고 있고 각각 마지막으로 추가된 작업에 대해서 마다 새로운 이벤트를 형성해주고 있다.

 

 

위와 같은 구조는 모든 데이터를 받고 난 다음에 한번에 업데이트 하고 싶은 경우나 하나의 소스가 변경될 때마다 최종적으로 입력된 Observable 소스를 불러오고 싶은 경우 유용하다. 추상적으로 서술해서 감이 잘 오지 않는데 각 경우의 예를 생각해보면 이해하기 쉬울 것 같다. 

 

첫번째의 예로는 서버에서 받은 정보를 앱의 화면에 노출할 때로 볼 수 있을 것 같다. 컨텐츠 페이지에서 페이지의 정보와 댓글 리스트를 화면에 뿌려줘야 하는데 이 리스트가 다른 api로 분기되어 있다면 각각을 분리해서 호출해야 하고 화면 업데이트도 한번에 이뤄지지 않아 따닥따닥 이뤄질 수 있다. 이렇게 분리된 경우는 로딩 아이콘 표시하는 작업도 각 호출별로 설정해줘야해서 번거로운데 이럴 때는 combineLatest 구조를 이용해서 모든 소스를 받고 난 다음에 업데이트 하도록 두 소스를 묶어줄 수 있다.

 

코드로 표현하면 이렇다. Flowable의 combineLatest 함수로 묶어서 댓글과 컨텐츠 리스트를 Flowable 소스로 받고 모든 소스가 도달 했을 때 loading 이 종료되었음을 false로 알릴 수 있다.

 

Flowable.combineLatest(
    getReplyList(),
    getContentList(),
    BiFunction<List<String>, List<String>, Pair<List<String>, List<String>>> {
        replyList, contentList ->
        Pair(replyList, contentList)
    }
)
    .doOnNext { loading.onNext(false) }
    .doOnNext { pair -> 
        val replyList = pair.first
        val conentList = pair.second
    }
    .subscribe()
    
private fun getReplyList(): Flowable<List<String>> = Flowable.fromCallable {
    listOf("Hello", "World", "RxJava", "Flowable")
}

private fun getContentList(): Flowable<List<String>> = Flowable.fromCallable {
    listOf("This", "is", "developer")
}

 

두번째 경우로는 웹사이트에서 로그아웃이 된 경우 새로고침시 파라미터로 넣을 정보가 Observable 소스로 된 경우다. 로컬 저장소에 유저의 id를 이용해 로그인 유무를 판단하고 유저 상태 변경시 홈 api를 호출해야 하는데 이때 Observable 소스로 된 로컬 토큰 정보가 필요하면 id 가 변경될 때마다 token 정보를 새롭게 받도록 combineLatest로 묶어줄 수 있다. 코드로 표현하면 아래와 같다. 

 

Flowable.combineLatest(
    id().distinctUntilChanged(),
    token(),
    BiFunction<Long, String, Pair<Long, String>> { id, token ->
        Pair(id, token)
    }
)
    .flatMap { pair -> 
        val id = pair.first
        val token = pair.second
    
        home(id, token)
    }
    .subscribe()

 

combineLatest로 묶을 수 있는 소스의 개수는 최대 9개까지 가능하다. 그런데 이정도로 많은 소스를 combineLatest로 묶는 경우는 거의 희박할 뿐더러 관리하기도 어렵다. 가능하면 3, 4개 까지 사용하는게 코드 유지 관리 측면에서도 좋다.

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

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
MediaCodec - Encoding  (0) 2020.06.21

status bar 영역 덮는 view 만들기

개발/안드로이드 2020. 6. 24. 22:57 Posted by 아는 개발자

 

안드로이드 런처 화면 상단을 보면 status bar 영역을 배경 영역이 덮고 있는 것을 볼 수 있다. 이렇게 status bar 영역까지 확장할 경우 유저에게 꽉찬 느낌을 줄 수 있어서 틱톡을 비롯한 몇몇 앱에서도 활용하고 있다. 이번 포스트에서는 이렇게 status bar 영역까지 확장하는 방법을 소개한다.

 

 

1. AppTheme 설정

 

windowActionBar, windowNoTitle 속성을 추가하고 이 style 값을 activity에서 사용하도록 한다.

 

<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
	<!-- Customize your theme here. -->
	<item name="colorPrimary">@color/colorPrimary</item>
	<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
	<item name="colorAccent">@color/colorAccent</item>

	<item name="windowActionBar">false</item>
	<item name="windowNoTitle">true</item>
</style>

 

2. FullScreen 형태로 변경 

 

activity 또는 fragment에서 현재 window의 systemUiVisibility 플래그 값을 View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 로 변경하고 statusBarColor를 투명으로 바꿔준다. 

this.window?.apply {
    this.statusBarColor = Color.TRANSPARENT
    decorView.systemUiVisibility =
        View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN

}

 

이렇게 두가지 작업만 적용해도 아래 처럼 status bar 영역까지 차지하게 되는 것을 볼 수 있다. 그런데 앱에서 차지하고 있는 공간이 거의 흰색에 가깝다 보니까 status bar 아이콘들이 잘 보이지 않는 문제점이 있다. 

 

3. Status Bar Icon 색 변경

 

이런 경우에 직접 status bar 아이콘의 색깔을 어둡게 설정해줄 수 있다. View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR 플래그를 추가하면 아이콘이 light 배경일 때 사용할 수 있도록 어두운 아이콘으로 나오게 된다.

 

this.window?.apply {
    this.statusBarColor = Color.TRANSPARENT
    decorView.systemUiVisibility =
        View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN

}

 

적용하면 아래의 그림처럼 아이콘 색이 변한다

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

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
MediaCodec - Encoding  (0) 2020.06.21
MediaCodec - Decoding  (0) 2020.05.24