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

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

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

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

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

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 - 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

움직이는 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

AudioTrack: Discontinuity detected

카테고리 없음 2021. 4. 30. 17:53 Posted by 아는 개발자

몇몇 영상에 대해 Exoplayer 라이브러리에서 AudioTrack: Discontinuity detected [expected 61128344, got 60909659] 에러를 뿜고 있었다. 영상도 렉이 걸리고 소리도 끊김이 있어서 한참 Exoplayer 라이브러리를 디버깅했었는데 이건 플레이어의 문제가 아니라 영상 파일이 문제였다. 사운드 인코딩 과정에서 버퍼 처리를 잘못해서 생긴 문제였는데 자세한 설명을 위해 아래 그림을 참조해보자.

 

 

트랜스코딩 과정은 Decoder에서 출력되는 Decoder Buffer를 Encoder Buffer에 복사하고 다시 Encoder에 입력하는 과정으로 이뤄진다. 위 그림에서 보면 Encoder Buffer와 Decoder Buffer모두 사이즈가 2048이기 때문에 Encoder에서는 빈 공간 없이 사운드 스트림을 채울 수 있다.

 

그런데 몇몇 기종에서는 Encoder Buffer의 크기와 Decoder Buffer 크기가 다르기도한다.  아래 그림은 encoder buffer의 기본 크기가 4096이고 Decoder Buffer의 기본 크기는 2048이다. 별다른 처리를 하지 않으면 Encoder Buffer는 앞부분에만 버퍼를 채우게 된다.

 

 

그러면 최종적으로 인코딩된 사운드의 스트림은 아래 그림처럼 중간중간에 빈 공간이 남아있게 되고, 결과적으로 불연속 오디오 로그가 띄게 되는 것이다. 플레이어에서는 받은 그대로 처리하기 때문에 소리도 이상하게 출력될 수 밖에 없다.

 

 

그러면 해결하는 방법은? 간단하다. Encoder Buffer를 꽉꽉 채워서 인코딩 하는 것이다. 아래 그림처럼 encoder와 decoder 버퍼의 크기가 다르다면 다음 것을 받아서 채워 넣은 다음 Encoder에 넣는다. 버퍼의 크기는 2의 제곱으로 떨어지기 때문에 나눠떨어지는 문제는 염려하진 않아도 된다. 

 

반대로 Decoder Buffer가 Encoder Buffer가 더 큰 경우도 있다. 이런 경우 불연속 에러는 발생하지 않겠지만 소리가 이상하게 들리게된다. 해결 방법은 비슷하다. 앞에선 Encoder Buffer에 두개의 decoder 버퍼를 담았다면 이번엔 Decoder Buffer를 쪼개서 넣는다. 아래 그림을 보면 Decoder 버퍼 앞부분은 Encoder Buffer 1, 뒷부분은 Encoder Buffer 2에 담아서 넣는다. 이런 방식이다.

 

 

버그를 많이 경험하다보니 강제로 인코딩 디코딩을 많이 배우게 된 것 같다. 물론 내가 배운게 아직 전부는 아니기 때문에 더 배워야할 것은 많지만 예전처럼 헤메지는 않게 된 것 같아서 기분은 좋다

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

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

 

현재 안드로이드 의존성 주입 라이브러리로는 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라는 말씀이죠?

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

Lottie 라이브러리

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

 

서비스 출시 막판 디자인 작업에선 다이나믹하고 아기자기한 애니메이션 효과를 주고 싶어하는 디자이너와 물리적 효과 구현의 어려움과 잠재적인 에러 때문에 주저하는 개발자 사이에 보이지 않는 갈등이 존재하곤 했었는데, 손쉽게 애니메이션 효과를 줄 수 있는 Lottie 라이브러리가 나오면서 조금이나마(?) 해소됐다. Lottie 라이브러리는 기존 Animator 클래스에 있는 함수와 속성 변수를 거의 그대로 가져다 쓰면서도 애니메이션 효과로 디자이너 분이 작업해둔 json 파일만 붙여두면 되기 때문에 사용하기가 정말 쉽다. 이번 포스트에서는 lottie json 파일을 안드로이드에서 사용하는 방법에 대해서만 간단히 소개하려고한다.

 

1. 라이브러리 임포트 

 

현재 최신 버전은 3.4.0 까지 릴리즈 됐다. build.gradle에 추가해준다

dependencies {
  implementation 'com.airbnb.android:lottie:$lottieVersion'
}

 

2. XML 파일에 Lottie 추가

 

새롭게 추가된 속성은 app:lottie_fileName 정도다. 여기에 Lottie 애니메이션 효과를 줄 파일을 넣으면 된다. 이 파일은 프로젝트에 app/src/main/assets 에 넣어두면 된다. 여기에 안넣어두면 이 파일 못찾는다고 앱이 크래쉬 날 수 있으니 주의하자.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <com.airbnb.lottie.LottieAnimationView
        android:id="@+id/fr_lottie_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:lottie_fileName="img_lottie.json"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

 

3. 자바 파일에서 애니메이션 실행 

 

앞서 XML 파일에서 선언해둔 뷰 오프젝트를 직접 실행 해주면 된다. 상황에 따라서 어느 시점에 이 애니메이션을 실행할지 결정할 수 있다. 메인쓰레드에서 playAnimation() 함수만 실행하면 자동적으로 동작한다.

 

class LottieFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        fr_lottie_view.playAnimation()
    }

실행해보면 아래 gif 처럼 실행된다.

4. 여러가지 옵션 

 

단순히 애니메이션을 실행하는 것 뿐만 아니라 여러가지 옵션을 줄 수 있다. 옵션도 무수히 많은데 자주쓰는 것들만 소개해보면 이런 것들이 있다.

 

4.1 무한 재생하기

 

애니메이션을 몇번 재생할지 결정하는 변수인데 이 값을 -1로 주면 무한대로 재생하게 할 수 있다. 로딩 애니메이션을 표현할때 유용하다

 

fr_lottie_view_infinite.apply { repeatCount = -1 }

 

4.2 거꾸로 재생하기

 

애니메이션 스피드를 조정할 수 있는 변수인데 이 값을 -1f로 설정하면 거꾸로 재생할 수 있다. 값은 -1f ~ 1f 사이이고 물론 속도도 조절 가능 하다.

fr_lottie_view_reverse.apply { speed = -1f }

 

두 효과를 적용한 결과 아래 그림처럼 하나는 무한대로, 다른 하나는 반대로 돌아가게 된다

728x90

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

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
MediaCodec - Getting Started  (0) 2020.05.24

MediaCodec - Decoding

개발/안드로이드 2020. 5. 24. 13:09 Posted by 아는 개발자


MediaCodec을 사용하기 위해선 기본적인 비디오 영상 처리에 대한 지식이 필요하다. 영상처리도 깊이 들어가면 한도 끝도 없을 것 같은데 이 포스트에서는 MediaCodec 을 이용해 Decoding 할 때 반드시 알고 있어야 하는 지식 정도로만 간추려서 소개하려고 한다.

 

여기서 사용한 예제 코드는 grafika https://github.com/google/grafika 저장소의 MoviePlayer.java 코드에서 deprecated 된 부분만 바꿨다. 실제로 동작하는 코드를 확인하고 싶다면 여기서 프로젝트를 받아 실행해보면 될 것 같다

 

1. Definition

 

Decoding은 영상 파일이 가지고 있는 정보를 추출해내는 작업이다. 우리가 흔히 볼 수 있는 영상플레이어들은 모두 비디오 파일을 읽고 이 정보를 화면에 뿌리는 디코딩 작업을 거친다. 디코딩 작업에는 화면 프레임 정보 뿐만 아니라 비디오가 가지고 있는 음성 파일도 포함한다. 영상에서 긁어온 정보들을 컨트롤해서 화면에 뿌려주는게 영상플레이어의 역할이다. 안드로이드에서는 MediaCodec 라이브러리를 통해 영상과 음성에 대해서 디코딩을 할 수 있다.

 

2. Create

 

MediaCodec 라이브러리를 이용해서 디코딩 작업을 담당하는 객체를 생성할 수 있다. 아래 코드에서 createDecoderByType 함수가 Decoder를 생성하는 함수다. 생성 전에 수행하는 작업을 볼 필요가 있는데, 앞의 MediaExtractor 클래스가 하는 역할은 소스 파일에서 비디오의 Meta 정보를 가져오는 역할을 한다. 비디오가 가지고 있는 Meta 정보로는 비디오의 bitrate, width, size 등등을 가져올 수 있는데 디코딩 작업을 할때는 비디오의 현재 압축 방식인 MIME이 필요하다. 이 압축방식은 비디오의 확장자마다 다른데 거의 모든 영상을 mp4로 담고 있는 현재는 대부분 h264 방식을 따르고 있다. 정확히 이게 어떤 방식으로 압축되는지는 나도 잘 모른다. 아무튼 이 정보를 통해 비디오 디코더를 읽어올 수 있다.

 

extractor = new MediaExtractor();
extractor.setDataSource(mSourceFile.toString());
int trackIndex = selectTrack(extractor);
if (trackIndex < 0) {
	throw new RuntimeException("No video track found in " + mSourceFile);
}
extractor.selectTrack(trackIndex);

MediaFormat format = extractor.getTrackFormat(trackIndex);

// Create a MediaCodec decoder, and configure it with the MediaFormat from the
// extractor.  It's very important to use the format from the extractor because
// it contains a copy of the CSD-0/CSD-1 codec-specific data chunks.
String mime = format.getString(MediaFormat.KEY_MIME);
decoder = MediaCodec.createDecoderByType(mime);
decoder.configure(format, mOutputSurface, null, 0);
decoder.start();

 

decoder.configure 함수의 첫번째 인자로는 아까 추출한 압축 방식을 전달했고 두번째 인자로 mOutputSurface라는 값을 전달하고 있다. mOutputSurface는 decoder로 받은 정보를 화면에 뿌려줄 도화지 역할을 하는데 위와같이 configure함수에 두번째 인자로 넣으면 디코딩 된 정보를 자동으로 화면에 뿌려줄 수 있게 된다. 

 

2. Extract Data

 

다음으로 할 작업은 디코더를 이용해 실제로 비디오 파일에서 영상 정보를 추출하는 것이다. 아래 그림은 MediaCodec에 관한 구글 개발자 문서에서 가져온 것인데, 코덱의 일종인 디코더는 영상을 가져올때 크게 input 작업과 output 작업을 거친다. 초록색으로 칠해진 클라이언트는 비디오에서 정보를 읽는 작업이고, 작은 정사각형을 채워넣는 작업은 앞서 클라이언트에서 읽어온 정보를 디코더 버퍼에 읽어온 채워넣는 작업이다. 이것 모두 개발자가 해야하는 일이다. 이 작업도 아래의 그림처럼 크게 두가지 단계로 정리해볼 수 있을 것 같다.

 

 

 

2.1 Input Phase

 

비디오에서 읽어온 정보를 Input 버퍼에 채워넣는 일이다. 코드를 하나하나 살펴보자. dequeueInputBuffer 함수는 받아온 나중에 받아올 정보를 채워너 넣을 수 있는 공간을 할당받는 함수다. 리턴값으로 index를 주는데 이 index 값은 엄청 길다란 배열의 index 값으로 생각하면 된다. 이 index 값을 받아서 데이터를 쓸 위치를 넣을 수 있다.

 

다음 작업으로는 비디오에서 데이터를 읽어오는 작업이다. 여기서 사용된 extractor 변수는 앞서 생성 작업에서 선언한 변수와 동일하다. 객체 내에 읽은 부분에 대한 iterator가 포함되어 있어서 어디까지 읽었는지 정보를 담고 있다. 코드 맨 마지막에 advance 함수를 통해 읽을 위치를 변경하는 것이 가능하다.

 

마지막으로 extractor에서 뽑아온 정보를 Input 버퍼에 넣어야한다. 앞어 읽어온 sample data의 리턴 값에 따라서 Input 버퍼에 넣는 정보가 다른데 이 값이 마이너스인 경우에는 모든 데이터를 읽은 경우이기 때문에 Input Buffer에 플래그 값으로 END_OF_STREAM을 넣어준다. 그 외의 경우에는 유효한 데이터인 것으로 보고 Input Buffer에 넣고 플래그 값을 0으로 넣는다. 함수의 네번째 인자로 시간 정보 값을 주는데 이 값은 현재 읽어온 버퍼가 비디오에서 몇초대에 위치하고 있는지에 대한 정보다.

 

int inputBufIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC);
if (inputBufIndex >= 0) {
    if (firstInputTimeNsec == -1) {
        firstInputTimeNsec = System.nanoTime();
    }

    ByteBuffer inputBuf = decoder.getInputBuffer(inputBufIndex);
    // Read the sample data into the ByteBuffer.  This neither respects nor
    // updates inputBuf's position, limit, etc.
    int chunkSize = extractor.readSampleData(inputBuf, 0);
    if (chunkSize < 0) {
        // End of stream -- send empty frame with EOS flag set.
        decoder.queueInputBuffer(inputBufIndex, 0, 0, 0L,
                MediaCodec.BUFFER_FLAG_END_OF_STREAM);
        inputDone = true;
        if (VERBOSE) Log.d(TAG, "sent input EOS");
    } else {
        if (extractor.getSampleTrackIndex() != trackIndex) {
            Log.w(TAG, "WEIRD: got sample from track " +
                    extractor.getSampleTrackIndex() + ", expected " + trackIndex);
        }
        long presentationTimeUs = extractor.getSampleTime();
        decoder.queueInputBuffer(inputBufIndex, 0, chunkSize,
                presentationTimeUs, 0 /*flags*/);
        if (VERBOSE) {
            Log.d(TAG, "submitted frame " + inputChunk + " to dec, size=" +
                    chunkSize);
        }
        inputChunk++;
        extractor.advance();
    }
} else {
    if (VERBOSE) Log.d(TAG, "input buffer not available");
}

 

2.2 Output Phase

 

Output Phase에서는 방금전 Input Phase에서 넣어둔 input buffer 정보를 추출하는 일을 한다. 각 비즈니스 로직에 따라서 추출한 정보를 화면에 뿌려주기도 하고 아니면 새로운 비디오를 만드는 작업으로 사용할 수도 있을 것 같다. dequeueOutputBuffer는 아까 dequeueInputBuffer 함수에서 넣어둔 정보를 가져오는 역할을 한다. 첫번째 인자는 out 타입으로 받아온 정보를 저장하고 리턴 값으로는 현재 디코더의 상태 값을 나타낸다.

 

TRY_AGAIN_LAYER는 현재 읽을 수 있는 Input buffer가 없을 때 발생한다. Input Buffer를 분명히 넣어 줬는데도 이 플래그 값이 발생하는데 input buffer를 여러차례 넣고 나면 제대로 읽을 수 있게 된다.  FORMAT_CHANGED는 디코더의 output format에 변화가 생겼다는 뜻인데 디코딩 작업에서는 딱히 중요한 점이 없다.

 

mBufferInfo 에서 받아온 정보의 플래그 값을 보게 되는데 END_OF_STREAM 가 포함되어 있으면 버퍼는 마지막인 것이다. 전에 Input Phase에서 END_OF_STREAM 플래그를 넣었던 바로 그녀석이 맞다. 

int decoderStatus = decoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);
if (decoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
    // no output available yet
    if (VERBOSE) Log.d(TAG, "no output from decoder available");
} else if (decoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
    MediaFormat newFormat = decoder.getOutputFormat();
    if (VERBOSE) Log.d(TAG, "decoder output format changed: " + newFormat);
} else if (decoderStatus < 0) {
    throw new RuntimeException(
            "unexpected result from decoder.dequeueOutputBuffer: " +
                    decoderStatus);
} else { // decoderStatus >= 0
    if (firstInputTimeNsec != 0) {
        // Log the delay from the first buffer of input to the first buffer
        // of output.
        long nowNsec = System.nanoTime();
        Log.d(TAG, "startup lag " + ((nowNsec-firstInputTimeNsec) / 1000000.0) + " ms");
        firstInputTimeNsec = 0;
    }
    boolean doLoop = false;
    if (VERBOSE) Log.d(TAG, "surface decoder given buffer " + decoderStatus +
            " (size=" + mBufferInfo.size + ")");
    if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
        if (VERBOSE) Log.d(TAG, "output EOS");
        if (mLoop) {
            doLoop = true;
        } else {
            outputDone = true;
        }
    }

    boolean doRender = (mBufferInfo.size != 0);

    // As soon as we call releaseOutputBuffer, the buffer will be forwarded
    // to SurfaceTexture to convert to a texture.  We can't control when it
    // appears on-screen, but we can manage the pace at which we release
    // the buffers.
    if (doRender && frameCallback != null) {
        frameCallback.preRender(mBufferInfo.presentationTimeUs);
    }
    decoder.releaseOutputBuffer(decoderStatus, doRender);
    if (doRender && frameCallback != null) {
        frameCallback.postRender();
    }

    if (doLoop) {
        Log.d(TAG, "Reached EOS, looping");
        extractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
        inputDone = false;
        decoder.flush();    // reset decoder state
        frameCallback.loopReset();
    }
}

 

doRender 변수를 결정하는 요인은 mBufferInfo.size 가 0보다 클 때 인데, 이 정보는 받아온 정보가 화면에 뿌려줄 영상 정보인지 아닌지를 의미한다. 그래서 이 값이 유효하다면 각 비즈니스 로직에 따라서 화면에 뿌려주거나 음성을 재생하면 된다. 아래 코드에서는 frameCallback 함수에서 읽어온 정보에서 시간 정보만 추출해 가져가고 있다. 경우에 따라선 비디오 디코딩 정보를 담고 있는 mOutputSurface를 OpenGL에 그려주어 인코더의 input에 넣어주기도 한다. CTS 테스트 코드를 보면 추출한 오디오 버퍼 정보를 바로 인코더에 넣는 것을 볼 수 있다.

 

읽어온 정보에 대한 처리가 끝나면 releaseOutputBuffer를 통해 이 정보에 대한 처리가 완료 됐음을 처리한다.

 

2.3 release 

 

EndOfStream에 도달해 디코딩 작업이 완료되면 사용한 Decoder를 반드시 릴리즈 시켜줘야한다. 이것은 release 함수로 가능하다.

 

728x90

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

Lottie 라이브러리  (0) 2020.06.24
MediaCodec - Encoding  (0) 2020.06.21
MediaCodec - Decoding  (0) 2020.05.24
MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18

MediaCodec - Getting Started

개발/안드로이드 2020. 5. 24. 10:19 Posted by 아는 개발자

 

0. FFmpeg - 한계

 

동영상과 관련된 작업을 처리하는 툴로 가장 유명한 것은 아마 FFmpeg 일 것이다. 이 라이브러리에서는 영상의 트랜스코딩(압축)을 지원할 뿐만 아니라 영상내 텍스트/이미지 삽입 또는 영상을 회전시키고 자를 수 있는 기능도 제공하며 실행도 대부분의 개발자들에게 익숙한 형태인 커맨드라인 딱 한줄만 입력하면돼 비디오에 대해서 잘 모르는 사람들도 쉽게 사용할 수 있다. 하지만 FFmpeg은 이 모든 작업들이 소프트웨어적으로 구현되어 있어 느리다는 단점이 있고 C, C++인 로우 레벨로 만들어진 빌드 파일을 JVM 위에 돌아가는 자바 언어단에서 별개의 도입하는 것은 꺼리낌이 있다. 개발 외적으로는 GPL 라이센스를 가지고 있어서 이 라이브러리의 수정사항을 공개해야한다는 법적 이슈가 있고 무엇보다 대중적으로 사용하는 h264 압축 방식을 사용하는 경우(mp4 파일을 생성하는 경우) 특허 이슈가 있다고 한다. 이 특허문제에 대해선 인터넷상에서 갑론을박이 많은데 가장 중요한 주체인 FFmpeg 공식 홈페이지에서도 "자기들은 변호사는 아니라 잘 모르겠다"고 답변하는 것으로 보아 자유롭게 사용하기에는 찝찝한 툴이다.

 

ffmpeg 공식 홈페이지 Patent issue에 대한 답변. "We do not know" 문구가 눈에 띈다.

 

1. MediaCodec - 어쩔 수 없이 써야하는 존재

 

 

플랫폼 개발자들도 이런 문제점을 인식해서인지 영상을 처리할 수 있는 고유의 라이브러리를 도입했는데 안드로이드의 경우 MediaCodec 라이브러리가 이에 해당한다. FFmpeg의 한계점을 극복하고자 도입한 라이브러리이기에 더이상 라이센스 문제도 없고 JVM위에서 동작하는 안드로이드에서 사용하기에 적합한 형태이며 하드웨어 가속을 지원해 소프트웨어적으로 돌아가는 FFmpeg보다 빠르다.

 

하지만 단점도 만만치 않다. FFmpeg은 트랜스코딩 뿐만 아니라 다양한 툴을 포함하고 있고 영상에 대해서 잘 몰라도 쉽게 사용할 수 있었다. 그러나 MediaCodec은 영상 정보를 추출하는 디코딩 작업과 바이너리 정보를 조합해 새로운 영상을 만드는 인코딩 작업만 제공할 뿐이며 원래 FFmpeg에 있었던 텍스트를 삽입하고 영상을 자르는 기능은 모두 스스로 만들어야 한다. 즉 이제는 디코딩/인코딩이 무엇인지, 영상 파일은 어떤식으로 이뤄져 있는지 그리고 텍스트를 삽입하고 영상을 자를 수 있는 그래픽의 기본 지식까지 겸비해야 한다는 뜻. 평소에 게임을 만들어본 사람이나 영상쪽에 관심있는 사람이 아니면 이쪽에 대해서 아마 잘 모를 것이다. 그리고 공부하려고 해도 진입장벽이 있는 부분이라 러닝 커브가 높다.

 

아쉽게도 단점은 이것 만이 아니다(ㅠㅠ). MediaCodec 라이브러리는 직접 하드웨어 장비와 연계된 부분이기 때문에 구글은 API만 뚫어주고 퀄컴, 삼성 LSI와 같은 칩 제조사에서 이 부분을 직접 구현했는데 이 부분이 칩(AP)에 따라서 다르다. 똑같은 갤럭시 스마트폰, 동일한 모델임에도 불구하고 국내에서 주로 사용하는 엑시노스 칩에서는 동작하는 반면 해외에서 사용하는 퀄컴 칩에서는 동작이 안될 수가 있다. 그리고 똑같은 안드로이드 버전이고 퀄컴칩을 사용하는데도 불구하고 사용하는 칩의 버전이 달라 갤럭시 노트8은 되고 갤럭시 S9은 되는 현상도 발생한다. 물론 이 경우는 코드를 잘못짠 것에 해당하기는 하나... 같은 플랫폼에서 똑같은 코드가 칩마다 다르게 동작할 수 있다는 점은 플랫폼 개발자로서 영 찝찝한 점이다. 안드로이드 버전별로 대응해왔던 것에서 이제는 국내용, 해외용도 모두 다 봐야 한다는 뜻이니까. 칩제조사와 플랫폼 벤더가 통합된 iOS 개발자들이 부러워지는 순간이다.

 

더 난감한 점은 게다가 이쪽 부분은 제조사에서 코드를 숨겨놔 에러가 발생해도 코드도 볼 수 없다는 사실이다... Logcat 메시지에서도 에러가 발생하면 알려주는 정보가 0x 로 시작하는 16진수의 플래그값 외에 알려주는게 더 없다. 스택오버플로우에라도 의지해볼 수 있다면 좋으련만 이상하게도 MediaCodec 관련 정보는 별로 없다. MediaCodec이 2012년도에 등장했는데도 아직까지 이렇게 정보가 많지 않다는 것을 보면 다들 MediaCodec으로 개발한 정보를 숨겨놓는건지 아니면 쓰려다가 지레 포기하고 외부 라이브러리를 사용한 것인지. MediaCodec을 이용한 오픈소스 프로젝트가 몇몇 있기는 한데 코드에서 정작 중요한 정보들은 byte code로 꽁꽁 숨겨놨다.(이럴거면 왜 공개했다고 한건지) 인터넷 상에서 정보를 찾기는 어렵고 개발하는데 난감하지만 대안이 없어 어쩔 수 없이 사용해야하는 라이브러리다.

 

 

2. MediaCodec - 개발 참고 자료

 

 

개발하기 어렵지만 그래도 참고할 만한 자료가 전혀 없는 것은 아니다. 단, 다른 라이브러리들처럼 친절한 문서 페이지는 기대하지 않는게 좋다.

 

 

2.1 CTS 코드

 

구글에서는 CTS (호환성 테스트) 검증에 사용한 코드를 공개하고 있다. 이 테스트 코드는 모든 제조사들이 출시하기 전에 PASS 해야하기 때문에 여기 코드들은 칩 디펜던시가 없이 모두 안정적으로 동작한다고 봐도 될 것 같다. https://bigflake.com/mediacodec/ 라는 사이트에서 MediaCodec과 관련된 CTS 테스트코드 주소와 테스트 목적에 대해서 짤막하게 소개해주고 있으니 여기서 구현하려는 것과 가장 가까운 테스트 코드를 참고하자. 테스트 코드를 보면 알겠지만 구글에서도 테스트 코드는 거지같이짜서  한눈에 보기가 쉽진 않다.

 

2.2 grafika 

 

구글에서 MediaCodec관련 문의가 하도 많이 들어와 만든 것인지 모르겠으나 MediaCodec 개발자로서는 한여름의 에어컨과도 같은 오픈소스다. https://github.com/google/grafika 여기에는 MediaCodec 라이브러리를 이용해 응용할 수 있는 무비 플레이어, 카메리 영상 처리, 비디오 트랜스코딩과 같은 다양한 예제를 담고 있다. README 페이지에 이 코드는 구글 공식 프로덕트가 아니고(그럼 구글 저장소에는 왜 있는건지?) 테스트를 제대로 하지 않아 안정적이지 않다고도 크게 써놔서 이 코드들이 모든 디바이스에서 동작할지는 확신 할 수 없지만, MediaCodec을 기본적으로 어떻게 써야할지 감을 익힐때 사용하면 유용하다. 

 

2013, 2014년도에 주로 작성되고 그 이후에는 최신 안드로이드버전 호환만 관리했기 때문에 모든 코드가 JAVA로 되어 있어 Kotlin으로 옮길 때 린트가 많이 생기는 단점이 있다.

 

728x90

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

MediaCodec - Encoding  (0) 2020.06.21
MediaCodec - Decoding  (0) 2020.05.24
MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18
Kotlin - Coroutine  (0) 2020.04.15

Navigator - Getting Started

개발/안드로이드 2020. 4. 20. 23:21 Posted by 아는 개발자

안드로이드 Navigator 라이브러리는 프래그먼트를 이용해서 화면을 전환하는 작업을 돕는 라이브러리다. 로그인후 메인 화면으로 이동하거나 글 작성하는 UX의 경우 저장하는 작업 까지 여러 화면을 거치게 되는데 이런 경우 여러개의 액티비티를 쓰거나, 매번 FragmentManager를 이용해서 메인 뷰를 차지하고 있는 Fragment를 교체(replace)해줘야 했다. 하지만 Navigator 라이브러리를 사용하면 이런 화면 전환 과정을 XML 파일로 관리할 수 있고 시각화도 가능해서 유지 관리에 도움이 된다.

 

먼저 XML 파일로 표시하면 이렇고,

 

fragment_nav_graph.xml

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/fragment_navi_example"
    app:startDestination="@id/start_fragment">

    <fragment
        android:id="@+id/start_fragment"
        android:name="kwony.kotlin.navigate.StartFragment">
        <action
            android:id="@+id/action_first_fragment"
            app:destination="@id/first_fragment"
            app:exitAnim="@anim/fragment_close_exit"/>
    </fragment>

    <fragment
        android:id="@+id/first_fragment"
        android:name="kwony.kotlin.navigate.FirstFragment">
        <action
            android:id="@+id/action_second_fragment"
            app:destination="@id/second_fragment"
            app:enterAnim="@anim/slide_in_left"/>
    </fragment>

    <fragment
        android:id="@+id/second_fragment"
        android:name="kwony.kotlin.navigate.SecondFragment"/>
</navigation>

 

이 정보는 미리보기로 이렇게 표시된다.

 

 

XML파일을 쭉 훑어보고 난 후 사진을 보면 대강 감이 올텐데 먼저 가장 최상위 startDestination은 시작하는 프래그먼트의 이름이다. 위의 사진에서는 start_fragment 가 이 화면 구성의 시작점이 된다. start_frament에서 action 속성이 하나 있는데 여기서 destination 값은 first_fragment, 바로 앞 start_fragment에서 화살표로 가리키는 클래스다. 마찬가지로 first_fragment 에서도 action 속성이 하나 있는데 여기서의 destination은 second_fragment이고 사진상에서는 second_fragment를 화살표로 가리키고 있다. 이처럼 navigatior 에서는 fragment의 action 속성을 통해 어떤 fragment로 이동해야하는지 정해줄 수 있다. 

 

app:exitAnim 속성 값은 프래그먼트가 사라질 때 줄 애니메이션 효과다. 기본으로 등록되어 있는 것을 사용해도 되고 직접 커스텀해서 넣을 수도 있다. 반대로 enterAnim은 프래그먼트가 생겨날 때 줄 수 있는 효과다. 간단하게 XML 파일의 형태로 넣을 수 있어서 쉽다.

 

위에서 설명한 내용을 적용하려면 navigator를 Activity에 넣고 선언한 Fragment들은 action 속성값에 선언된대로 이동하도록 코드를 호출 해야한다. 먼저 Activity 작업에 대한 코드는 다음과 같다.

 

0. Activity

 

Class쪽 수정 없이 XML 파일에 이미 만든 navigation 리소스를 넣는 구문만 추가하면 된다. 아래 소스만 넣으면 처음에 StartFragment 를 클래스에서 생성하지 않아도 자동으로 FragmentContainerView가 잡고 있는 영역에 StartFragment가 추가된다. 

 

그리고 여기서 app:defaultNavHost="true" 로 선언했는데 이렇게 두면 시스템상의 백버튼 액션을 가로채서 이 Navigator에서 사용할 수 있다. 이 말은 즉 SecondFragment로 까지 이동한 상태에서 백버튼을 누르면 그 이전에 stack에 쌓여 있는 FirstFragment로 이동하고 다시 한 번 백버튼을 누르면 그전에 stack에 있는 StartFragment로 이동할 수 있다는 것이다. 프래그먼트가 화면에서 비중있는 역할을 하는 경우 필수적인 속성이 된다.

 

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="kwony.kotlin.di.activity.DaggerRootActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/fragment_nav_di_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

1. StartFragment

 

StartFragment에서는 FirstFragment로 이동할 수 있는 작업이 있어야 하는데 임의로 TextView를 누르면 그 작업이 호출 되도록 했다. Click Listener 내부를 보면 findnavController().navigate 함수가 부르는데 여기의 인자가 XML에서 StartFragment 내부에 선언한 action 이다.

 

class StartFragment: DaggerFragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        fr_nav_tv.text = "StartFragment"

        fr_nav_tv.setOnClickListener {
            findNavController().navigate(R.id.action_first_fragment)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_nav_children, container, false)
    }
}

 

2. FirstFragment

 

StartFragment와 코드는 거의 흡사하고 차이가 있는 부분은 아까 선언한 action의 id 값을 바꿔주는 부분만 다르다.

 

class FirstFragment: DaggerFragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        fr_nav_tv.text = "FirstFragment"

        fr_nav_tv.setOnClickListener {
            findNavController().navigate(R.id.action_second_fragment)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_nav_children, container, false)
    }
}

 

이 포스트는 Navigator의 아주 기본적인 기능에 대해서만 소개한 것이라 아직 라이브러리의 장점을 모두 말하지 못했다. 숨겨진 기능을 확인해보고 싶으시다면 구글 문서를 참고하거나 이 카테고리의 다음 글을 기대해도 좋을 것 같다.

728x90

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

MediaCodec - Decoding  (0) 2020.05.24
MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18
Kotlin - Coroutine  (0) 2020.04.15
Kotlin으로 깔끔한 Builder를 만들어보자  (0) 2020.04.14

안드로이드 그림자(Shadow) 효과 넣기

개발/안드로이드 2020. 4. 18. 11:43 Posted by 아는 개발자

안드로이드에서 그림자 효과를 넣는 방법으로는 UI의 elevation 속성 값을 조정하는 것과 직접 그림자용 리소스 파일을 만드는 방법이 있다. 이번 포스트에서는 이 두가지의 사용 방법과 각각의 장단점을 소개해보려고 한다.

 

1. elevation 값 조정하기 

 

UI에 가장 쉽게 섀도우 효과를 입힐 수 있는 방법이다. 안드로이드 API21 부터 UI 뷰들에 elevation 이라는 속성값이 추가 됐는데 이 값을 넣으면 UI가 Z축으로 위로 튀어나와 그림자 효과를 줄 수 있게 된다. 

 

elevation 값을 조정해 그림자 효과를 준 경우.

코드는 다음과 같다.

 

<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <ImageView
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_margin="50dp"
            android:elevation="20dp"
            android:background="@color/colorPrimary" />
    </FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

 

elevation 값을 조정해서 효과를 줄 때는 주의해야할 점이 두가지 있는데 첫번째는 elevation 값에 넣어준 수치 만큼 View 주변에 여백을 충분하게 주어야 한다는 것이다. elevation으로 만든 그림자는 View의 width/height 영역 밖에서 발생하기 때문에 이 부분의 여백을 주지 않으면 그림자 효과가 발생하지 않는다. 

 

미리보기 화면을 통해 확인해보면 FrameLayout 내부의 ImageView 주변에 여백이 있는 것을 확인 할 수 있다.

두번째로는 background값이 투명하면 안된다. 불투명한 값으로 셋팅을 해줘야한다. 왜 불투명한 background 값을 셋팅해줘야하는지는 아직 잘 모르겠다; 하지만 투명한 값으로 세팅하면 그림자 효과가 나타나지 않는다.

 

이 방법은 편하긴 하지만 API21 버전부터 사용할 수 있고 하단부에만 그림자 효과를 줄 수 있다는 단점이 있다. 상하좌우 모두 그림자 효과를 주어야 할 때는 사용 할 수 가 없다. 이런 경우에는 직접 리소스 파일로 그림자 효과를 만들어야 한다.

 

2. 그림자용 리소스 파일 만들기

 

선이나 사각형을 코드로 만들 때 사용했던 XML 파일을 이용해서 그림자 효과를 줄 수 있다. 설명에 앞서서 아래 예시 코드와 이 코드를 입힌 UI 결과물을 먼저 보자. 코드가 길지만 반복 구문이 많으니 대강 훓어보는 것을 추천한다

 

shadow_test.xml

 

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" >

    <!-- Drop Shadow Stack -->
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#00CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#06CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#09CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#0BCCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#0DCCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#10CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#12CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#15CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#17CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#1ACCCCCC" />
        </shape>
    </item>

    <!-- Background -->
    <item>
        <shape>
            <solid android:color="@android:color/white" />
        </shape>
    </item>

</layer-list>

 

ImageView에 위에서 만든 리소스를 background로 넣었다.

 

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.appcompat.widget.LinearLayoutCompat
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="RtlSymmetry">

        <ImageView
            android:layout_width="150dp"
            android:layout_height="150dp"
            android:layout_gravity="center"
            android:background="@drawable/shadow_test"/>
    </androidx.appcompat.widget.LinearLayoutCompat>
</androidx.constraintlayout.widget.ConstraintLayout>

 

이 코드를 이용해 그림자 효과를 입혀본 결과는 다음과 같다.

 

예제 코드를 사용해 그림자 효과를 입힌 결과

사진을 자세히 보면 흰 사각형 바깥쪽에 촘촘히 작은 선들이 있는 것을 볼 수가 있다. 이건 위 예제 코드에서 2.5dp 기준으로 각각 색깔이 다른 사각형을 넣었기 때문이다. 여러개의 작은 장면들을 조합해서 연속된 애니메이션 효과로 보이게 한 것 처럼 이 그라데이션 효과도 작은 사각형들을 합해서 그림자처럼 보이게 만든 효과다. 

 

이 방법은 약간의 노가다가 필요하긴 하지만 개발자가 그림자 효과를 자유자재로 커스텀이 가능하다는 장점이 있다. 어떤 부분에 좀더 강조를 세게 주고 싶다거나 좌측 상단, 우측 하단, 상화좌우 전체에 그림자 효과를 선택해서 줄 수 있다. 

 

elevation을 이용한 방법과 차이가 있다면 이 방법은 그림자 영역이 뷰의 영역에 포함되어 있다는 것이다. 아래 그림을 보면 왼쪽 그림의 보라색 사각형이 elevation을 이용해서 그림자 효과를 준 경우고 하얀색 사각형이 리소스를 이용해서 그림자 효과를 준 경우인데, 미리보기 상으로는 하얀색 사각형이 더 작아보이지만 두 ImageView의 가로 세로 너비 값은 오른쪽 그림에서도 알 수 있듯이 동일하다. 리소스를 사용하면 그림자 영역을 View 내부에서 사용하기 때문에 원래 생각했던 ImageView의 크기와 약간 차이가 발생 할 수 있다. 상황에 따라서 단점이 될 수도 있고 장점이 될 수 도 있는 기능이라 섣불리 판단 할 수는 없을 것 같다. 단 차이점은 유의해서 알고가는 것이 좋을 것 같다.

 

728x90

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

MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18
Kotlin - Coroutine  (0) 2020.04.15
Kotlin으로 깔끔한 Builder를 만들어보자  (0) 2020.04.14
Exoplayer2 사용하기  (0) 2020.04.12

Exoplayer2 사용하기

개발/안드로이드 2020. 4. 12. 15:02 Posted by 아는 개발자

 

0. ExoPlayer란?

 

안드로이드에서 영상 재생을 위해 사용하는 플레이어로 기본 내장 라이브러리인 MediaPlayer가 있었는데 스트리밍 서비스가 주류를 이루면서 구글에서 DASH와 SmoothStreaming을 지원하는 ExoPlayer 라이브러리를 도입했다. 유튜브, 네이버 동영상 프레임들도 Exoplayer를 사용하고 있다고 하니 앞으로 안드로이드 동영상 플레이어는 ExoPlayer가 주류를 이룰 것 같은 예감이다. 아니면 이미 그런지도 모르겠고.

 

ExoPlayer는 MediaPlayer에서 이미 지원하는 기능에서 새로운 기능을 추가한 것이기 때문에 로컬/인터넷 동영상 파일 재생은 당연히 가능하고 Android Media Codec 기반으로 작업을 해서 Media Codec가 도입되기 시작한 안드로이드 기기 (API16 이상)에선 대부분 문제 없이 동작 한다. 물론 일부 기능은 더 높은 API 버전이 필요하긴 하지만 이는 거의 특수한 경우인 것 같다. 이번 포스트에서는 ExoPlayer를 사용하는 방법을 간단히 다룰 예정이다.

 

1. Components 

 

ExoPlayer: ExoPlayer의 라이브러리중 Renderer, 즉 화면에 뿌려주는 역할을 하는 컴포넌트다. ExoPlayer 인터페이스로 커스텀하게 만들 수 있으며 SimpleExoPlayer는 ExoPlayer에서 제공하는 컴포넌트다. 특별히 커스터마이즈 할 것이 아니면 이걸 그냥 가져다 쓰는게 좋다. 아래 함수를 통해 만들 수 있다.

 

val simpleExoPlayer = ExoPlayerFactory.newSimpleInstance(requireContext(), trackSelector)

 

TrackSelector: SimpleExoPlayer 생성 과정에서 두번째 인자로 전달된 클래스는 영상의 Track 정보를 세팅하는 역할을 한다. 이 정보라면 예를 들면 선호하는 오디오 언어는 어떤 것인지, 비디오 사이즈는 무엇인지, 비디오 bitrate는 어떤 것으로 할지 등등 이런 것들을 말한다. 이것도 Renderer와 동일하게 따로 커스터마이즈 할 수 있긴 하나 특별한 이유가 없다면 라이브러리에서 기본으로 만들어 둔 것을 쓰는게 가장 좋다.

 

아래 코드는 TrackSelector를 만들 때 AdaptiveTrackSelection 팩토리를 사용한 예시다. AdaptiveTrackSelection 팩토리 클래스는 현재 bandwidth 정보를 이용해 현재 선택된 track에서 최상의 퀄리티를 제공하는 역할을 한다고 한다. 더 자세한 내용은 라이브러리 내부 주석을 살펴보는 것이 좋을 것 같다. Streaming 서비스를 한다면 이쪽 클래스를 주요하게 보게될 것 같다.

 

val bandwidthMeter = DefaultBandwidthMeter()
val videoTrackSelectionFactory = AdaptiveTrackSelection.Factory(bandwidthMeter)
val trackSelector = DefaultTrackSelector(videoTrackSelectionFactory)

 

MediaSource: 영상에 출력할 미디어 정보를 가져오는 클래스다. ExtractorFactory 클래스를 통해 만드는데 이 클래스는 DataSource 클래스를 주입해서 만든다. 여기서 사용한 DefaultDataSource도 다른 라이브러리처럼 ExoPlayer에서 uri 형태로된 데이터를 읽어오기 위해 기본적으로 제공하는 라이브러리다. 특별한 형태의 DataSource 클래스를 사용하고 싶다면 커스터마이즈가 가능하다. 

 

val extractorFactory = ExtractorMediaSource.Factory(DefaultDataSourceFactory(context, Util.getUserAgent(context, context!!.applicationInfo.packageName)))
val mediaSource = extractorFactory.createMediaSource(Uri.parse(mediaPath))

 

Player: 영상 재생을 위해선 미디어를 읽어오는 작업뿐만 아니라 영상을 UI 상에 뿌려줄 수 있는 뷰어가 필요한데 ExoPlayer용 뷰어가 따로 있다. 아래 코드를 XML에 넣으면 된다. 재생바, 앞으로 당기기기 같은 기본적인 UI 기능도 지원한다.

 

<com.google.android.exoplayer2.ui.PlayerView
        android:id="@+id/fr_main_player"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#000"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

 

2. Play Video 

 

앞서 소개한 컴포넌트들을 하나의 코드로 조합하면 재생이 가능하다. 코드의 순서는 설명한 순서와 조금 다른데 이는 클래스 생성 후에 주입하기 위함이다. exoPlayer.prepare(mediaSource)는 영상 정보를 가져오는 작업이고 fr_main_player.player.playWhenReady는 준비되면 영상을 시작하는 함수다.

 

val mediaPath = "http://somewhere...."
val bandwidthMeter = DefaultBandwidthMeter()
val videoTrackSelectionFactory = AdaptiveTrackSelection.Factory(bandwidthMeter)
val trackSelector = DefaultTrackSelector(videoTrackSelectionFactory)
val exoPlayer = ExoPlayerFactory.newSimpleInstance(requireContext(), trackSelector)
val extractorFactory = ExtractorMediaSource.Factory(
    DefaultDataSourceFactory(
        context,
        Util.getUserAgent(context, context!!.applicationInfo.packageName)
    )
)
val mediaSource = extractorFactory.createMediaSource(Uri.parse(mediaPath))

fr_main_player.player = exoPlayer
exoPlayer.prepare(mediaSource)
fr_main_player.player.playWhenReady = true

 

3. Extension 

 

Player.Listener: 영상 재생중 로딩에 실패하거나 Track 속성이 바뀌거나 혹은 영상 재생이 완료된 경우에 대해서 리스너를 등록해줄 수 있는데 이 경우들은 뷰어에 리스너를 등록해서 구현이 가능하다. 아래 코드를 통해 어떤 경우에 대해서 콜백 호출이 가능한지 확인 해볼 수 있다. 추가로 아래 코드에선 영상 재생이 완료된 경우 다시 재생하도록 구현했다.

 

fr_main_player.player.addListener(object: Player.EventListener{
    override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters?) {}
    override fun onSeekProcessed() {}
    override fun onTracksChanged(trackGroups: TrackGroupArray?, trackSelections: TrackSelectionArray?) {}
    override fun onPlayerError(error: ExoPlaybackException?) {}
    override fun onLoadingChanged(isLoading: Boolean) {}
    override fun onPositionDiscontinuity(reason: Int) {}
    override fun onRepeatModeChanged(repeatMode: Int) {}
    override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {}
    override fun onTimelineChanged(timeline: Timeline?, manifest: Any?, reason: Int) {}
    override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
        if (playbackState == Player.STATE_ENDED) {
            fr_main_player.player.seekTo(0)
            fr_main_player.player.playWhenReady = true
        }
    }
})

 

CacheDataSource: 인터넷으로 영상을 받는 경우 여러번 재생을 할 때 마다 동일한 데이터를 계속 인터넷으로 불러오게돼 데이터를 낭비할 수도 있는 문제가 있다. ExoPlayer에서는 이 문제점을 해결하고자 별도의 미디어 데이터 저장 공간으로 Cache를 뒀다. 이것도 다양하게 커스터마이즈 할 수 있으나 가장 기본적인 사용 방법은 아래 코드와 같다. 

 

ExtractorMediaSource.Factory 함수에서 호출 할 수 있도록 임의의 클래스를 DataSource.Factory의 인터페이스를 구현한 형태로 만든다. 리턴 값으로는 CacheDataSource가 되는데 여기서 생성자에서 캐시가 가져야할 정보를 입력하게 된다. 아래 코드 보면 캐시의 크기도 설정 할 수 있도 플래그를 넣을 수 있는 것도 확인 할 수 있다.

 

private class CacheDataSourceFactory internal constructor(
    private val context: Context,
    private val defaultDataSourceFactory: com.google.android.exoplayer2.upstream.DataSource.Factory,
    private val maxCacheSize: Long,
    private val maxFileSize: Long,
    private val url: String
) : com.google.android.exoplayer2.upstream.DataSource.Factory {
    override fun createDataSource(): com.google.android.exoplayer2.upstream.DataSource {
        val evictor = LeastRecentlyUsedCacheEvictor(maxCacheSize)
        val simpleCache = SimpleCache(File(context.cacheDir, "media"), evictor)
        return CacheDataSource(
            simpleCache,
            defaultDataSourceFactory.createDataSource(),
            FileDataSource(),
            CacheDataSink(simpleCache, maxFileSize),
            CacheDataSource.FLAG_BLOCK_ON_CACHE or CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR,
            null
        )
    }
}

 

위 코드를 이용한 호출부는 다음과 같다. 앞서 설명한 CacheDataSource 팩토리 클래스에서 두번째 인자로 DataSourceFactory를 넣었는데 아래 구현부 코드를 확인해보면 이전에 만든 DefaultDataSourceFactory 클래스를 넣는 것을 볼 수 있다. 외부 데이터를 불러오는 작업은 기존 데이터 클래스를 따라 간다는 뜻이다.

 

val extractorCacheFactory = ExtractorMediaSource.Factory(
    CacheDataSourceFactory(requireContext(), DefaultDataSourceFactory(
        context,
        Util.getUserAgent(context, context!!.applicationInfo.packageName)
    ), MAX_CACHE_SIZE, MIN_CACHE_SIZE, mediaPath)
)

val mediaSource = extractorCacheFactory.createMediaSource(Uri.parse(mediaPath))

 

 

728x90

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

Kotlin - Coroutine  (0) 2020.04.15
Kotlin으로 깔끔한 Builder를 만들어보자  (0) 2020.04.14
Exoplayer2 사용하기  (0) 2020.04.12
FragmentManagers Android  (1) 2020.04.06
ViewModelProviders.of deprecated  (0) 2020.04.06
Exoplayer에 stetho 적용하기  (0) 2020.03.16

FragmentManagers Android

개발/안드로이드 2020. 4. 6. 15:56 Posted by 아는 개발자

 

FragmentManager는 동적인 UI를 제공하기 위한 클래스인 Fragment를 관리하는 컨트롤러 역할을 한다. Manager라는 이름에서 예상 할 수 있듯이 FragmentManager를 사용하면 현재 UI에 Fragment를 추가할 수도 있고 있는 것을 교체할 수도 있으며 제거까지 가능하다. 호출하는 함수는 Activity인지, Fragment인지에 따라 다른데 일반적으로 Activity는 supportFragmentManager를 호출하게 되고, Fragment는 childFragmentManager 또는 parentFragmentManager를 통해 호출한다. 지금까지 개발 할 때는 각각의 차이를 확인하지 않고 '일단 동작부터 되도록' 에 주안점을 뒀는데 이번 포스트를 통해서 각각의 차이점과 적절한 쓰임새를 정리해보려고 한다.

 

0. 정의

 

FragmentManager의 경우에는 안드로이드 문서도 그닥 꼼꼼히 정리 되어있지 않고 스택오버플로우에서도 관심있게 다루는 주제가 아니라 참고할 만한 글이 별로 없었다. 그래서 지금까지 개발하면서 내가 나름대로 내린 사전적(?) 정의는 이렇다.

  • supportFragmentManager(SFM): Activity랑 상호작용하는(interacting) Fragment를 관리하는 클래스. Activity 클래스에서 호출이 가능하며 Activity 고유의 클래스다.

  • parentFragmentManager(PFM): 부모 UI 컴포넌트(Activity일 수도 있고 Fragment 일 수도 있다) 고유의 FragmentManager. Fragment 클래스에서 호출이 가능하다.

  • childFragmentManager(CFM): Fragment 고유의 FragmentManager 클래스. Fragment 별로 모두 다르다.

1. 그림 

 

SFM과 CFM과는 달리 PFM은 고유의 값이 아니라 특정 객체를 가르키는 값이다.  아래의 그림처럼 PartyActivity, PartyFragment 내부에 여러개의 PayFragment를 둔다고 해보자. 그러면 이들의 관계는 PartyActivity가 최상위 부모, PartyFragment는 부모, PayFragment는 자손이 되는 형태가 될 것이다. 

 

PartyActivity -> PartyFragment -> PayFragment

 

그러면 각각의 UI 요소들이 갖고 있는 FragmentManager는 아래와 같은 형태를 따르게 된다. 붉은 선으로 표현한 부분은 서로 동일한 객체인 것을 의미한다. Activity의 SFM을 이용해서 PartyFragment를 관리하고 있으므로 PFM는 부모인 PartyActivity 의 고유 FragmentManager, SFM을 가리키게 된다. 마찬가지로 PayFragment는 PartyFragment의 CFM으로부터 생성 됐으므로 PayFragment의 PFM은 부모인 PartyFragment의 CFM을 가리키게 된다.

 

PayFragment를 PartyFragment의 CFM을 이용해서 생성한 경우

 

로그를 통해 객체의 값을 확인해보면 위와 같은 구조를 가지는 것을 확인 할 수 있다. PartyFragment의 PFM 값은 PartyActivity의 SFM과 동일하고, PayFragment의 PFM값은 PartyFragment의 CFM과 동일하다.

 

UI 클래스 별로 PFM, SFM, CFM의 값을 출력한 결과

2. 주의점

 

PayFragment를 PartyFragment의 CFM으로 생성하지 않고 PFM으로 생성하는 경우 다음과 같이 그림이 달라진다. PartyFragment의 PFM은 PartyActivity의 SFM이며 PayFragment의 생성주체는 PartyActivity의 SFM이기 때문에, PayFragment의 PFM은 자연스레 PartyActivity의 SFM을 가리키게 된다.

 

PayFragment를 PartyFragment의 PFM으로 생성한 경우

 

물론 이런 형태여도 동작하는데는 큰 문제가 없을 것이다. 하지만 FragmentManager는 Fragment와 생성주기를 함께 하기 때문에, PartyFragment가 삭제되도 PayFragment는 SFM의 인스턴스로 남아있게 된다. 물론 이게 남아있는다고 동작상에 크게 흠을 주거나 메모리 릭을 유발하는 것은 아니지만 의도하지 않은 형태로 개발을 하다보면 정체모를 버그가 튀어나올 수 있으니 염두에두고 있는게 좋을 것 같다.

 

728x90
  1. 안발자 2020.07.21 12:03  댓글주소  수정/삭제  댓글쓰기

    Fragment와 Activity 사이에 fragmentManager 사용에 관해 고민이 있었는데 관련해서 좋은 인사이트가 되었습니다 감사합니다 :)

ViewModelProviders.of deprecated

개발/안드로이드 2020. 4. 6. 15:01 Posted by 아는 개발자

ViewModel을 주입할 때 주로 사용하는 ViewModelProviders 클래스는 lifecycle-extension 라이브러리가 2.2.0 버전업 되면서 통째로 Deprecated가 됐다. 하지만 ViewModelProvider(뒤에 s만 빠진 클래스가 맞다) 클래스를 통해 동일한 기능을 수행하도록 할 수 있다.

 

BEFORE

@Module(includes = [BaseActivityModule::class])
abstract class MainActivityModule {
    @Binds
    abstract fun provideActivity(activity: MainActivity): FragmentActivity

    @Module
    companion object {
        @Provides
        @JvmStatic
        fun provideViewModel(activity: FragmentActivity, viewModelFactory: ViewModelFactory): MainViewModel
                = ViewModelProviders.of(activity).get(MainViewModel::class.java)
    }
}

AFTER

@Module(includes = [BaseActivityModule::class])
abstract class MainActivityModule {
    @Binds
    abstract fun provideActivity(activity: MainActivity): FragmentActivity

    @Module
    companion object {
        @Provides
        @JvmStatic
        fun provideViewModel(activity: FragmentActivity, viewModelFactory: ViewModelFactory): MainViewModel
                = ViewModelProvider(activity, viewModelFactory).get(MainViewModel::class.java)
    }
}

 

fragment의 부모 activity 를 넘겨서 fragment와 activity가 동일한 viewmodel을 바라보게하는 기능도 정상적으로 동작한다

 

@Module(includes = [BaseFragmentModule::class])
abstract class PayFragmentModule {
    @Binds
    abstract fun provideFragment(fragment: PayFragment): Fragment

    @Module
    companion object {
        @Provides
        @JvmStatic
        fun provideViewModel(fragment: Fragment, viewModelFactory: ViewModelFactory): PartyViewModel 
                = ViewModelProvider(fragment.requireActivity(), viewModelFactory).get(PartyViewModel::class.java)
    }
}

 

함수단위면 몰라도 클래스 하나를 통째로 Deprecated 하는 것은 흔치 않는 일인 것 같은데 기존에 있는 ViewModelProvider 클래스에만 집중해서 개선하기 위함이지 않을까 조심스럽게 추측해본다. 그리고 기존에 있던 ViewModelProviders 코드도 대부분 ViewModelProvider를 호출하는 형태였기 때문에 둘이 겹치는 점도 많았던 것 같고. 

728x90

Exoplayer에 stetho 적용하기

개발/안드로이드 2020. 3. 16. 10:54 Posted by 아는 개발자

0. 소개

 

Exoplayer는 안드로이드 영상 재생플레이어로 많이 사용되는 오픈소스 프로젝트다. 요즘처럼 스트리밍으로 조각된 영상을 받는 경우엔 클라이언트의 재생 플레이어에서도 서버로 여러번 영상에 대한 요청을 보내게 되는데 이때 안드로이드 네트워크 인스펙터인 stetho를 사용하면 Exoplayer에서 보낸 요청들을 볼 수 있어서 디버깅 할 때 편리하다.

 

implementation 'com.google.android.exoplayer:extension-okhttp:2.7.0'

 

1. Exoplayer + Stetho

 

build.gradle 파일에 exoplayer extension 라이브러리를 추가한다. 감사하게도 exoplayer에서 stetho를 이용해 디버깅을 할 수 있도록 사전 작업을 해두었다.

 

 

라이브러리를 추가한 다음에는 안드로이드 Exoplayer 코드에서 http 요청 부분을 아래의 코드로 변경한다. OkHttpDataSourceFacotry는 DefaultHttpDataSourceFactory와 거의 동일해 동작에는 큰 차이가 없다고 봐도 무방하다.

 

+//    private val mMediaDataSourceFactory = DefaultDataSourceFactory(context, bandwidthMeter,
+//            DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.applicationInfo.packageName), bandwidthMeter))
+
+    private val mMediaDataSourceFactory = DefaultDataSourceFactory(context, bandwidthMeter,
+            OkHttpDataSourceFactory(OkHttpClient.Builder().addNetworkInterceptor(StethoInterceptor()).build(), Util.getUserAgent(context, context.applicationInfo.packageName), bandwidthMeter))

 

2. Stetho

 

만약 애플리케이션에 StethoInterceptor를 사용하지 않고 있었다면 아래의 작업을 추가해야한다. build.gradle 파일에 stetho 라이브러리를 추가하고

 

implementation "com.facebook.stetho:stetho-okhttp3:1.5.1"

 

Application으로 선언된 클래스에 Stetho를 초기화해준다

 

public class DemoApp extends DaggerApplication {
@Override
    public void onCreate() {
        super.onCreate();
        Stetho.initializeWithDefaults(this);

 

3. 결과 

 

테스트 한 결과 영상 파일들을 쪼개서 보낸 요청들을 Stetho를 이용해서 확인 할 수 있었다

 

 

728x90

RxJava: defer, fromCallable

개발/안드로이드 2020. 2. 15. 16:16 Posted by 아는 개발자

1. defer


Observable 클래스내에 포함된 defer() 함수는 관찰하고 있는 대상의 값을 구독한 이후 시점부터 볼 때 사용한다. 즉 subscribe 함수가 불린 시점부터 대상의 값을 관찰한다. 좀 더 이해를 쉽게 하고자 Person이라는 클래스를 만들어봤다.

class Person {
    var name: String = "None"

    fun observableName(): Observable<String> 
            = Observable.just(name)

    fun observableDeferName(): Observable<String>
            = Observable.defer { Observable.just(name) }
}


Person 클래스에는 수정이 가능한 name 변수와 name을 Observable로 변환해주는 observableName() 함수, 그리고 코드는 거의 비슷한데 앞에 defer 함수가 붙어있는 observableDeferName() 함수가 있다. 이 두 함수의 차이를 알아보고자 onCreate 함수에서 Person 클래스에서 선언한 함수값을 이용해 다음과 같이 코드를 짜봤다. 두 함수를 통해 Observable 객체를 만든 후 person의 name 변수 값을 수정한 다음 Observable 객체에서 구독받은 값을 출력하는 간단한 예제다.

val person = Person()
val observableName = person.observableName()
val observableDeferName = person.observableDeferName()

person.name = "selfish developer"

observableName.subscribe {
    Log.d(TAG, "observableName: " + it)
}

observableDeferName.subscribe {
    Log.d(TAG, "observableDeferName: " + it)
}

실행 결과 출력되는 로그는 다음과 같다.



observableDeferName은 subscribe된 시점부터 값을 보기 때문에 수정한 person 객체의 name 값이 갱신되었고 observableName은 생성 시점의 값을 받아오기 때문에 초기 값으로 세팅된 값을 가져온다. 


그런데 아래 코드는 똑같은 값을 출력한다.

person.name = "selfish developer"
person.observableName().subscribe {
    Log.d(TAG, "person.observableName(): " + it)
}

person.observableDeferName().subscribe {
    Log.d(TAG, "person.observableDeferName(): " + it)
}


그 이유는 관찰하고 있는 두 객체의 생성 시점이 모두 name 변수값이 업데이트 된 이후기 때문이다. 관찰용 객체를 함수로 선언하지 않고 Person 클래스 내의 변수로 바꾸면 앞서 보인 예시와 동일하게 서로 다른 값을 출력하게 된다.

class Person {
    var name: String = "None"

    val observableName 
            = Observable.just(name)
    val observableDeferName 
            = Observable.defer { Observable.just(name) }
}

person.name = "selfish developer"

observableName.subscribe {
    Log.d(TAG, "observableName: " + it)
}

observableDeferName.subscribe {
    Log.d(TAG, "observableDeferName: " + it)
}


2. fromCallable


Observable 클래스의 형제격인 Maybe, Flowable, Single 클래스에서는 fromCallable 함수가 defer와 같은 역할을 한다. 테스트를 해보고자 Observable과 동일하게 코드를 짜봤다.

class Person {
var name: String = "None"

fun singleName(): Single<String>
        = Single.just(name)

fun singleCallableName(): Single<String>
        = Single.fromCallable { name }
}

val singleName = person.singleName()
val singleCallableName = person.singleCallableName()

person.name = "selfish developer"

singleName.subscribe { it ->
    Log.d(TAG, "singleName: " + it)
}

singleCallableName.subscribe { it ->
    Log.d(TAG, "singleCallableName: " + it)
}

실행 결과 defer와 동일하게 fromCallable이 붙은 함수는 구독한 시점 이후에 갱신된 값을 읽어온다.




3. 총평


실행 결과는 신기하기도 하지만 실제로 사용할때는 꽤 실수가 잦을 것 같은 기능인 것 같다. 가능하면 매번 새로운 Observable 객체를 생성하는 함수를 따로 변수로 만들어두지 않고 바로 구독하게 해 만들어서 갱신 타이밍 이슈를 피하는게 좋지 않을까 싶다.

728x90

코틀린 apply, also, let, run, with

개발/안드로이드 2020. 2. 9. 14:21 Posted by 아는 개발자


자바에 비해 코틀린이 가지는 가장 큰 장점은 코드를 간결하게 작성 할 수 있는 것이라고 생각하는데 모든 객체에 기본적으로 제공하는 범위함수인 apply, also, let, run, with 들이 이 이점을 살리는데 큰 도움이 된다.


이 함수의 차이점에 대해서 설명한 글은 코틀린 공식 문서도 있고 다른 개발 블로그에도 무수히 많지만, 범위 함수에서 강조하는 수신객체와 람다식과 관련된 내용은 문서를 읽는 것 보다는 직접 코드를 짜면서 체험해 볼 때 이해하기가 쉽다. 이번 포스트에서는 apply, also, let, run, with를 언제 사용해야하는지에 대해서 수신객체에 관련된 내용을 제외하고 사용이 필요한 경우만 간략하게 소개해보려고 한다.


1. apply 


apply는 객체의 property 값을 적용할 때 사용한다. 어떤 객체를 선언할 때 생성자만으로 값을 세팅할 수 없다면 apply를 통해서 값을 따로 붙여서 연속적으로 값을 세팅할 수 있다. 아래의 두 코드는 모두 동일한 결과를 가지는데 apply 함수를 사용한 경우가 더 명시적이다.


val adam = Person("Adam").apply { 
    age = 20     
    city = "London"
}

val adam = Person("Adam")
adam.age = 20
adam.city = "London"


2. also 


also는 속성 변경을 허용하지 않고 로그를 출력하거나 값의 유효성을 검증하고 싶을 때 사용한다. 아래 코드처럼 also 문 앞에 있는 코드는 property를 바꿀 수 없다. 나도 모르게 저지르는 실수를 사전에 차단하고 싶을 때 사용하면 유용하다.


val numbers = mutableListOf("one", "two", "three")
numbers
    .also { println("The list elements before adding new one: $it") }
    .add("four")


3. let 


객체가 null이 아닌 코드를 실행하는 경우 사용한다. Null pointer 에러로 크래쉬가 나는걸 막을 때 꽤나 유용한 범위함수다. 주로 많이 사용하는 코드다. Java의 Optional.ofNullable 이렇게 길게 쓸 필요가 없어 간결해서 좋다.


val str: String? = "Hello"   
//processNonNullString(str)       // compilation error: str can be null
val length = str?.let { 
    println("let() called on $it")        
    processNonNullString(it)      // OK: 'it' is not null inside '?.let { }'
    it.length
}


4. run 


객체에 포함된 함수를 실행하고 그 결과를 반환할 때 사용한다. 아래 코드를 보면 리스트 타입인 numbers에 포함된 add 함수를 run 내부에서 실행하고 있고 마지막 구문에 e로 끝나는 문자열의 개수를 countEndsWithE 변수에 넣어주고 있다. 실행 결과는 주석으로 처리된 부분을 읽어보면 된다.


나는 이 함수는 자주 사용하지는 않는다. 굳이 함수를 먼저 실행한 다음에 리턴타입이 필요할 일도 없어서. apply랑 비슷한것 같기는 한데 그만한 유용성은 못찾겠다.


val numbers = mutableListOf("one", "two", "three")
val countEndsWithE = numbers.run { 
    add("four")
    add("five")
    count { it.endsWith("e") }
}
println("numbers: " + numbers)
println("There are $countEndsWithE elements that end with e.")
// numbers: [one, two, three, four, five]
// There are 3 elements that end with e.


5. with 


null이 될 수 없는 객체의 값을 출력할 때 사용한다. also랑 거의 차이가 없어서 나는 잘 사용하지 않는다. 


val numbers = mutableListOf("one", "two", "three")
with(numbers) {
    println("'with' is called with argument $this")
    println("It contains $size elements")
}


728x90

안드로이드 Loader

개발/안드로이드 2019. 7. 15. 21:20 Posted by 아는 개발자

앞선 포스트에서 onSaveInstanceState 콜백을 통해 화면을 전환하는 경우에도 데이터를 저장할 수 있는 방법을 배웠다. 그런데 AsyncTask 처럼 진행중인 작업에 대해서는 데이터를 저장할 수 있는 기능이 무의미할 것이다. 어디까지 데이터 작업을 처리 했으니 이때부터 다시 시작하라고 세세하게 할 수도 없는 노릇이고.


그래서 안드로이드에서는 Loader라는 라이브러리를 뒀다. 공식 문서에서는 FragmentActivity에 넣을 디스플레이 소스를 로드할 수 있는 기능으로 소개되고 있는데 일단은 별도의 쓰레드에서 돌아 Activity 생성 주기에 영향을 받지 않는 컴포넌트 정도로 이해하면 될 것 같다. 사용법은 아래와 같다.


1. implements LoaderManager.LoaderCallbacks<String>


MainActivity는 Loader API를 사용하는 Activity임을 명시 해둬서 Loader 콜백 함수들을 호출하도록 만든다.

public class MainActivity extends AppCompatActivity implements
        LoaderManager.LoaderCallbacks<string> { {

2. public Loader<String> onCreateLoader 


Activity에서 사용할 Loader 객체를 생성하는 콜백 함수를 구현한다. Loader는 AsyncTaskLoader와 CursorLoader가 있는데 CursorLoader 의 경우에는 DB에서 값을 읽어올 때 사용하고 AsyncTaskLoader 는 좀더 범용적으로 사용된다. 


2.1 protected void onStartLoading()


AsyncTaskLoader 가 생성 되면서 가장 먼저 실행되는 함수다. AsyncTask의 onPreExecute() 의 역할을 하는 것과 비슷하다. 백그라운드 작업 실행 하기 전에 필요한 셋팅 작업을 여기에 넣는다.


2.2 public String loadInBackground()


백그라운드 작업을 실행하는 함수다. 이름을 보면 감이 오겠지만  AsyncTask의 doInBackground(Void... voids)  와 동일한 기능을 하는 함수다. 반환 타입으로 세팅된 String은 결과 값의 타입이며 앞서 콜백을 implements 할 때 어떤 타입을 넣느냐에 따라 바꿀 수 있다.


2.3 public deliverResult(String result)


결과 값을 전달하는 함수다. 이 함수내에는 반드시 super.deliverResult(result);  가 포함되어 있어야지 결과 값이 최종적으로 전달 된다. 인자인 result 는 loadInBackground()에서 반환한 값이다


3. public void onLoadFinished(Loader<String> loader, String data)


AsyncTaskLoader 작업이 끝난 후에 불리는 함수이며 함수의 인자로 결과 값을 전달 받는다.  결과값을 화면에 업데이트 할 때 이 콜백 함수 내에 작업을 넣는다.


4. initLoader,restartLoader


생성한 Loader가 실행 될 수 있도록 호출한다. 아래 코드는 initLoader,restartLoader 함수를 실행한 예제 코드다.


LoaderManager loaderManager = getSupportLoaderManager();
Loader<string> searchLoader = loaderManager.getLoader(SEARCH_LOADER);
if (searchLoader == null) {
    loaderManager.initLoader(SEARCH_LOADER, queryBundle, this);
} else {
    loaderManager.restartLoader(SEARCH_LOADER, queryBundle, this);
}

함수의 첫번째 인자 값은 ID다. Loader마다 가지고 있는 고유한 Key값에 해당한다. 두번째 인자 값은 Bundle형태의 데이터 값이다. AsyncTaskLoader에게 이 데이터 값을 통해 값을 전달 할 수 있다. 세번째는 콜백함수다. 현재는 Activity가 Loader 콜백 함수를 구현해뒀기 때문에 this로 입력했다.

728x90

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

RxJava - Create 함수  (0) 2019.08.11
RxJava - Observable, Observer  (1) 2019.08.10
안드로이드 Loader  (0) 2019.07.15
onSaveInstanceState  (0) 2019.07.15
JAVA의 static  (0) 2019.04.03
안드로이드 Service  (0) 2019.03.19

onSaveInstanceState

개발/안드로이드 2019. 7. 15. 20:39 Posted by 아는 개발자


onCreate() onDestroy() 는  애플리케이션이 생성될 때와 종료 될 때 한 번씩만 불리는 콜백 함수로 알려져 있지만 디바이스의 설정 값이 갑자기 바뀌어 화면을 처음부터 새로 그려줘야 할 때도 불린다. 대표적으로 스마트폰을 회전 시키는 경우(rotate)가 이에 해당한다. 



확인해보기 위해 테스트 애플리케이션의 콜백 함수들에 로그를 넣고 에뮬레이터(오레오 8.1) 에 설치한 후 오른쪽으로 회전 해봤다. onPause() 함수부터 불리는 부분이 회전 후에 나온 로그며 이중에는 onCreate() onDestroy()도 포함되어 있다.


onCreate() onDestroy() 함수가 다시 불린다는 뜻은 회전하기 전까지 설정해둔 변수 값들이 모두 초기화 된다는 것을 의미하기도 한다. 만약 애플리케이션 화면에 특정 값을 바꾼 상태로 회전을 했다면 처음 애플리케이션을 실행한 상태로 화면이 바뀌기 때문에 지금까지 작업한 것들이 모두 날라가게 된다.


안드로이드에선 이런 상황을 대처하기 위해 onSaveInstanceState(Bundle outState) 라는 콜백 함수를 뒀다. 이 함수는 모든 인자들이 초기화 되는 onDestroy() 함수 호출 전에 실행되며 함수의 인자에 key-value로 여러가지 데이터를 넣을 수 있다. 변경된 인자는 최종적으로 onCreate(Bundle savedInstanceState) 함수의 인자 값으로 전달 된다.


구구절절히 코드로 보는 것이 더 이해하기 쉬울 것 같다. 먼저 onSaveInstanceState 함수내에 아래와 같이 임의의 문자열 데이터 값을 입력했다.

@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);

    logAndAppend("onSaveInstanceState");

    outState.putString(SAVE_INSTANCE_KEY,
            "onSaveInstanceState is called!\n");
}

그리고  onCreate 함수에선 인자에 key 값이 저장되어 있는지 확인 한 후 있으면 그 값을 TextView에 표시하도록 했다. 

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    mLifecycleDisplay = (TextView) findViewById(R.id.tv_lifecycle_events_display);

    if (savedInstanceState != null
            && savedInstanceState.containsKey(SAVE_INSTANCE_KEY)) {
        String savedString = savedInstanceState.getString(SAVE_INSTANCE_KEY);
        mLifecycleDisplay.setText(savedString);
    }
    logAndAppend(ON_CREATE);
}

그 결과 아래와 같이 회전 후 TextView에 로그 메시지가 추가되는 것을 확인 할 수 있었다



728x90

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

RxJava - Observable, Observer  (1) 2019.08.10
안드로이드 Loader  (0) 2019.07.15
onSaveInstanceState  (0) 2019.07.15
JAVA의 static  (0) 2019.04.03
안드로이드 Service  (0) 2019.03.19
AsyncTask  (0) 2019.03.13

AsyncTask

개발/안드로이드 2019. 3. 13. 23:57 Posted by 아는 개발자

0. 소개


AsyncTask는 파일 다운로드 완료후 사용자에게 완료됐다는 Toast 메시지를 보내는 유스케이스처럼 특정 백그라운드 작업이 종료되고 사용자에게 화면으로 알림을 전달해야하는 경우 유용한 클래스다. 코드가 직관적이고 응용하기 쉬워 자주 사용되는 라이브러리지만 몇초 이내에 종료될 수 있는 작업인 경우에만 사용이 가능하며 그것보다 긴 작업은 다른 클래스를 사용해야 한다.


안드로이드 공식문서의 예제 코드를 통해 사용 방법을 익혀보자.


private class DownloadFilesTask extends AsyncTask<URL, Integer, Long> {
     protected void onPreExecute(Integer... progress) {
         /* do nothing */
     }
     protected Long doInBackground(URL... urls) {
         int count = urls.length;
         long totalSize = 0;
         for (int i = 0; i < count; i++) {
             totalSize += Downloader.downloadFile(urls[i]);
             publishProgress((int) ((i / (float) count) * 100));
             // Escape early if cancel() is called
             if (isCancelled()) break;
         }
         return totalSize;
     }
     protected void onProgressUpdate(Integer... progress) {
         setProgressPercent(progress[0]);
     }
     protected void onPostExecute(Long result) {
         showDialog("Downloaded " + result + " bytes");
     }
 }



1. 상속 함수


클래스의 작업은 진행 순으로 크게 네가지로 구분된다.


protected void onPreExecute


이름에서 직감할 수 있듯이 AsyncTask 실행시 가장 먼저 불리는 함수다. 주로 여기에 백그라운드를 시작하기 전에 초기화해야하는 변수나 객체를 선언하는 용도로 사용한다.


protected Long doInBackground


백그라운드 작업이 실행되는 함수다. 예제 코드에서 확인 할 수 있듯이 여러 개의 인자를 전달받을 수 있으며 작업이 종료되면 특정 자료형의 결과 값을 리턴한다. 파라미터와 결과의 타입을 설정하는 방법은 다음 챕터에서 설명할 예정이다.


protected void onPostExecute

 

doInBackground  함수가 종료된 후 실행되는 함수다. doInBackground 함수의 결과값을 파라미터로 받으며 주로 예제 코드처럼 실행 완료메시지를 사용자에게 알림하는데 사용한다.


protected void onProgressUpdate


doInBackground 내에서 publishProgress 함수를 호출 할 때 불리는 함수이며 주로 작업의 진행 상황을 표시하는데 사용된다. 예제 코드에서는 다운로드 완료를 퍼센테이지로 전달했다.  



2. AsyncTask 인자


예제 코드의 DownloadFilesTask 클래스는 AsyncTask<URL, Integer, Long>을 상속하는데 여기서 부모클래스의 template 인자의 타입은 순서에 따라서 앞서 설명한 네가지 작업의 파라미터의 타입으로 매핑된다. 


첫번째 인자 (URL)


백그라운드로 실행하는 작업인 doInBackground 함수의 파라미터 타입이다. 주로 처리해야할 작업의 데이터를 전달하는 용도로 사용한다. 예제 코드에서는 URL 타입으로 설정해서 다운로드 받을 파일의 주소를 전달하는 용도로 사용하고 있다.


두번째 인자 (Integer)


진행 상황을 업데이트하는 onProgressUpdate 함수의 파라미터 타입이다. 예제 코드의 doInBackground 함수에서는 다운로드 진행 상황을 publishProgress 함수로 퍼센트로 전달하고 있으며 onProgressUpdate 함수는 Integer 형태로 받아서 화면에 표시하고 있다.


세번째 인자 (Long)


백그라운드 작업 종료후 실행하는 onPostExecute 함수의 파라미터 타입이다. doInBackground 함수의 리턴값이 onPostExecute 함수의 인자 값으로 전달되며 주로 작업의 정상종료 유무를 전달하기 위해 사용한다.



728x90

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

JAVA의 static  (0) 2019.04.03
안드로이드 Service  (0) 2019.03.19
AsyncTask  (0) 2019.03.13
ViewPager와 PageAdapter  (0) 2019.03.05
px, dp, sp 개념 정리  (0) 2019.02.16
Device screen dpi 값에 따라 처리하기  (0) 2019.02.16

ViewPager와 PageAdapter

개발/안드로이드 2019. 3. 5. 23:38 Posted by 아는 개발자


0. ViewPager와 PagerAdapter


ViewPager와 PagerAdapter는 아래 네이버 앱의 뉴스, 연애 스포츠 탭처럼 큰 화면 안에서 여러 개의 작은 화면을 좌우로 움직이는 UI를 구성할 때 사용하는 안드로이드 라이브러리다. ViewPager는 TextView, Button과 같이 화면의 인터페이스로 사용자가 조작할 수 있는 컴포넌트고 PagerAdapter는 ViewPager가 생성한 공간을 채우는 데이터 공간으로 보면 된다. 


특별히 화면에 이펙트를 넣을 생각이 없으면 ViewPager는 안드로이드에서 제공하는 라이브러리를 그대로 쓰고 내가 넣고 싶은 화면을 담은 별도의 PagerAdapter를 만들어서 ViewPager에 연결 시키면 된다.


이버의 뉴스, 연예, 스포츠 탭은 ViewPager와 PagerAdapter로 만들었을 것 같다. (아닐수도 있고)


1. Fragment + PagerAdapter 


큰 화면의 단위는 Activity이고 작은 화면의 단위는 주로 Fragment를 사용하기 때문에 안드로이드에서는 PagerAdater를 Fragment를 사용해서 구현하기 쉽도록 FragmentPagerAdatper를 만들어 뒀다. 이것과 이름이 비슷한 FragmentStatePagerAdapter가 있는데 Fragment 메모리 관리를 어떤식으로 할 것인가에 따라서 조금 차이가 난다. 깊게 들어가면 어려울 수 있으니 간단하게 ViewPager가 관리하는 페이지가 얼마 되지 않는 경우에는 FragmentPagerAdapter를, 많으면 FragmentStatePagerAdapter를 사용하도록 하자. 이 둘로 나눴지만 상속해서 작성하게될 Custom FragmentPagerAdatper의 코드상의 차이는 거의 없다.


2. Custom FragmentPagerAdapter


앞서 말한대로 여러 개의 화면을 좌우로 슬라이드 할 수 있는 화면을 만들기 위해선 별도의 PageAdapter가 필요하다. 안드로이드에서 FragmentPagerAdapter 클래스를 만들어 뒀으니 이를 상속해서 Custom PageAdapter를 만들 수 있다. 몇가지 오버라이드 함수들만 살펴보자.


public Fragment getItem(int position)


n번째 포지션의 Fragment Item 오브젝트 정보를 리턴한다. 채울 화면의 순서를 변경하고 싶다면 여기서 return 하는 Fragment 객체를 position 에 따라서 변경하면 된다. 반드시 구현해야하는 함수다.

    @Override
    public Fragment getItem(int position) {
        Fragment tab = null;

        switch (position) {
        case 0:
            tab = new PreviewTab();
            break;
        case 1:
            tab = new CodeTab();
            break;
        }

        return tab;
    }


public int getCount()


ViewPager에 넣을 화면의 개수를 리턴하는 함수다. 이것도 반드시 구현해야 하는 함수다.

    @Override
    public int getCount() {
        return this.mNumOfTabs;
    }

    @Override
    public CharSequence getPageTitle(int position) {
        return mTitles[position];
    }


public Object instantiateItem


View에 넣을 Page 객체를 리턴해주는 작업. 이미 FragmentPagerAdapter에서 구현을 해뒀기 때문에 따로 오버라이드 할 필요는 없다. 커스텀한 fragment를 사용할 수도 있긴 할텐데 가능하면 부모 함수를 바로 쓰는 것을 추천한다.

    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        Fragment fragment = (Fragment) super.instantiateItem(container, position);
        registeredTabs.put(position, (IMarkdownTab)fragment);
        return fragment;
    }


public void destroyItem


생성한 Fragment를 제거하는 작업이다. instantiateItem 함수에서 부모 함수를 이용했다면 destroyItem 에서도 부모 함수를 이용해서 처리하는 것이 좋다. 그래야만 부모 함수의 변수들과 싱크를 맞출 수 있으니까. 마찬가지로 가능하면 부모 함수를 바로 쓰는 것을 추천한다.

    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        super.destroyItem(container, position, object);
    }


전체 코드는 여기서 볼 수 있다.


3. ViewPager + Custom PageAdapter 


이제 만든 CustomPageAdapter를 xml의 ViewPager과 연결하면 된다. 아래와 같이 adapter 객체를 선언해준 후 ViewPager의 setAdapter 함수를 통해 연결 해줄 수 있다. 직관적인 코드라 이해하는데 별로 어렵지 않을 것 같다


adapter = new MarkdownPagerAdapter(getSupportFragmentManager(), Titles,
        selectedImgSrc, unselectedImgSrc, Titles.length);
viewPager = findViewById(R.id.view_pager);
viewPager.setAdapter(adapter);


4. ViewPager 페이지 선택 콜백함수


ViewPager 객체 내에 PageChange 리스너를 등록해서 페이지가 변경 될 때마다 특정한 작업을 하도록 만들 수 있다.


viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
    @Override
    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
    }

    @Override
    public void onPageSelected(int curPosition) {
        IMarkdownTab curImt = adapter.getRegisteredTab(curPosition);
        IMarkdownTab preImt = adapter.getRegisteredTab(prePosition);

        if (curPosition == prePosition || curImt == null)
            return;

        if (preImt!=null)
            preImt.cbPageUnSelected();

        curImt.cbPageSelected();
        prePosition = curPosition;
    }

    @Override
    public void onPageScrollStateChanged(int state) {

    }
});


이또한 함수명으로 충분히 언제 불리는지 이해할 수 있을 것 같다. onPageSelected 함수에 넣은 코드는 사용자가 선택한 페이지의 화면을 새로고침 하도록 만든 작업이다.  상황에 맞춰서 커스터마이즈하면 된다. 아 그리고 리스너는 여러 개를 등록 할 수 있다. 난 이 점을 이용해 아래 그림처럼 화면 포커스를 바꿀 때 새로고침과 동시에 바의 위치와 그림을 바꾸기도 했다.      

     

   

리스너를 이용해 화면 포커스가 바뀔 때 그림 이미지와 바의 위치를 변경 할 수 있었다

728x90

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

안드로이드 Service  (0) 2019.03.19
AsyncTask  (0) 2019.03.13
ViewPager와 PageAdapter  (0) 2019.03.05
px, dp, sp 개념 정리  (0) 2019.02.16
Device screen dpi 값에 따라 처리하기  (0) 2019.02.16
안드로이드 선 추가하기  (0) 2019.01.26

Device screen dpi 값에 따라 처리하기

개발/안드로이드 2019. 2. 16. 14:46 Posted by 아는 개발자

안드로이드 앱을 만들때면 무수히 많은 종류의 화면과 모두 호환 할 수 있는 UI를 만드는 일이 꽤나 골칫 거리다. 아이폰과 달리 안드로이드 폰은 제조사가 한군데만 있는 것도 아니고 또 같은 회사에서 만든 폰이라도 디스플레이 스펙이 제각각이라 이에대한 고려 없이 막만들었다간 화면이 깨져버리는 참사가 벌어지고 만다.


Android에서는 UI 개발자들이 이런 점들에 대해서 유연하게 대처 할 수 있도록 여러가지 API를 제공하는데 이번 포스트에서는 getResources().getDisplayMetrics().density 에 대해서 알아보려고 한다.


이 값은 디바이스의 160dpi 대비 디바이스의 screen density (dpi) 의 비율을 나타내며 실행되고 있는 디바이스 화면 스펙별로 값이 다르다. 디바이스의 화면이 320dpi면 2이고 420dpi면 2.625의 값을 가진다. 이 비율 값을 이용하면 다양한 하드웨어 스펙 별로 화면 값을 자동으로 조정 할 수 있게 된다.


int padding = (int) (TAB_IMAGE_VIEW_PADDING_PIV 
       / (getResources().getDisplayMetrics().density * 2));
imageView.setPadding(padding, padding, padding, padding);


이 값을 이용해 애플리케이션 오른쪽 상단 아이콘의 image view 주변 패딩 값을 조절하는데 사용 해봤다. View 클래스의 public void setPadding 함수는 인자를 절대 수치인 pixel 단위로 값을 받기 때문에 변환된 값을 전달해야한다. 그래서 getResources().getDisplayMetrics().density 값을 나눠서 화면 스크린 화질이 좋을 수록 값이 작아지도록 변경했다. 결과 아래 그림처럼 다른 화질을 가진 환경에서도 아이콘의 크기를 맞춰줄 수 있게 됐다.


728x90

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

AsyncTask  (0) 2019.03.13
ViewPager와 PageAdapter  (0) 2019.03.05
px, dp, sp 개념 정리  (0) 2019.02.16
Device screen dpi 값에 따라 처리하기  (0) 2019.02.16
안드로이드 선 추가하기  (0) 2019.01.26
SharedPreference  (0) 2019.01.10