쾌락코딩

잠금 화면 (Screen Lock) 구현 및 이슈 해결

|

앱 자체 잠금 화면(Lock Screen 구현)

앱 내 자체 잠금 화면이란, 안드로이드 설정 메뉴에서 잠금 화면을 설정하는게 아닌, 자신의 앱을 켜고 끌 때마다 나타나는 잠금 화면을 말한다.

최근에 앱 내 화면 잠금 기능을 구현할 일이 생겼다. 평소 ‘디바이스를 켜고 끌 때마다 잠금 해제를 하는데 앱을 켜고 끌 때도 해야 해?’ 라는 생각에 전혀 사용하지 않았던 기능이었다(사실 메이저 앱들이 이런 기능을 가지고 있는지 조차 몰랐다).

구현을 하기 위해 가장 먼저 들었던 원초적인 생각은 이랬다. “사용자가 어떤 액티비티를 보고 있다가 잠시 화면을 나가고 재진입 할 줄 모르니, BaseActivityonStart에서 ScreenLockActivity를 띄우자.” 그러나 이 생각대로 구현하게 된다면, A 액티비티가 B 액티비티를 호출할 때도 B 액티비티의 onStart에서 ScreenLockActivity를 띄울 테니 내가 원하는 형태가 아님을 쉽게 생각할 수 있었다.

떠오르는게 액티비티 또는 프래그먼트의 라이프사이클 뿐이라면 BaseActivityonStart()에서 어떻게든 처리할 수는 있을 것이다. screen on&off에 대한 이벤트를 브로드캐스트를 등록하여 받고, 단순 액티비티간 호출일 때는 예외 처리를 하고, 여러가지 상태 값들을 sharedPreference로 관리하여 구현하면 안될건 없다.

그러나 좀 더 단순하게, 더 멀리서 내다보면 앱 내에서 ScreenLockActivity를 띄워줘야 할 때는 앱이 Background에서 Foreground로 진입할 때 뿐이라는 사실을 알아차릴 수 있다. 힘들게 브로드 캐스트를 등록하지 않고도, 단순 액티비티 간의 이동일 때를 구분하지 않아도 된다. 액티비티나 프레그먼트의 라이프사이클이 아니라 앱 라이프사이클을 감지하는 ProcessLifecycleOwner 를 사용하면 간단하게 처리가 가능하다.

ProcessLifecycleOwner

ProcessLifecycleOwner 는 애플리케이션 프로세스의 라이프사이클을 감지할 수 있게 도와준다. 이를 통해 앱이 포그라운드로 진입했는지, 백그라운드로 진입했는지 알 수 있다.

사용법

애플리케이션 라이프사이클을 감지할 LifeCycleObserver 클래스를 만든다.

class AppLifecycleObserver(
    private val context: Context
) : LifecycleObserver {

    @OnLifecycleEvent(Lifecycle.Event.ON_START)
    fun onForeground() {
        if (isUserUseScreenLock) ScreenLockActivity.start(context)
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
    fun onBackground() {
        //background
    }
}

class ScreenLockActivity: Activity() {

	...

  companion object {
    fun start(context: Context) {
        val intent = Intent(context, ScreenLockActivity::class.java).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
        }
        context.startActivity(intent)
    }
  }
}

함수 이름은 의미가 없다. @OnLifecycleEvent 어노테이션이 중요하다. ON_START는 앱이 포그라운드에 진입할 때 호출되며, ON_STOP은 백그라운드로 진입하면 호출된다. 앱이 포그라운드에 진입할 때마다 ScreenLockActivity를 start 시켜주면 된다.

이제 ProcessLifecycleOwner 를 이용하여 AppLifecycleObserver를 등록하자.

class AppApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        ProcessLifecycleOwner.get().lifecycle
            .addObserver(AppLifecycleObserver(applicationContext))
				...
    }
}

Activity나 Fragment에서 등록한게 아니라, Application클래스에서 등록해주는 점을 유의하자. 이렇게 함으로써 어떤 브로드 캐스트도, 다른 어떤 복잡한 로직도 없이 대부분의 메이저 앱들과 거의 흡사하게 동작하는 ScreenLockActivity을 만들 수 있다.

보완점

바로 위에서 “거의 흡사하게” 라는 표현을 썻다. 높지 않은 확률이지만 위의 코드에는 ScreenLockActivity를 무시한 채 앱에 진입할 수 있는 심각한 버그가 있다. 사실은 그 버그와 그것을 해결하는 방법이 이 포스팅의 핵심이다.

App Process LifeCycle에 대해 잘 모른다면, 아마도 사용자가 비밀번호를 입력하지 않고 뒤로가기를 눌렀을 때 앱을 종료시키기 위해 ScreenLockActivity의 onBackKeyPressed()에다가 finishAffinity()를 작성할 것이다.

// ScreenLockActivity.kt
override fun onBackPressed() {
    finishAffinity()
}

문제가 없어보이지만 앱의 라이프사이클과 엮여 문제를 일으키는 부분이 있다.

finishAffinity()가 호출되면 앱은 종료되며 백그라운드로 진입(홈 화면 보임)하게 되는데, “백그라운드 진입 → 포그라운드 진입”을 아주 빠르게 실행했을 경우 OnStart 라이프사이클을 타지 않아 ScreenLockActivity가 무시된채 앱이 실행되어 버린다. back키를 누르는 순간 finishAffinity()가 호출되어 ScreenLockActivity는 사라졌지만, 포그라운드로 돌아왔을 때 다시 ScreenLockActivity이 불러와져야 하는데 그렇지 못하는 것이다.

이 부분 때문에 많은 고민을 하다가 떠오른 아이디어 하나. 잠금화면에 진입했을 때 뒤로가기를 누르면 어차피 앱을 사용하지 못하게 할테니, ScreenLockActivity 액티비티를 finishAffinity()하지 말고 그저 home 화면으로 이동만 시키면 어떨까? 이렇게 한다면 잠금 화면이 떠있는 상태에서 Back Key를 눌렀을 때 단순히 홈키로 이동하는 것이기 때문에, 사용자가 아주 빠르게 우리의 앱을 다시 클릭한다 하더라도(즉 OnStart()가 호출되지 않더라도) 다시 잠금 화면이 뜨게 된다. 심지어 아주 빠르게.

그래도 완벽하진 않을텐데?

완벽하지 않다고 느낄 부분이 있을 수 있다. 예를 들어 사용자가 앱을 잘 사용하던 중에 홈 키를 눌러 홈 화면으로 이동했고, 아주 빠르게 다시 앱 런쳐를 눌러 앱을 키면 OnStart() 가 호출되지 않기 때문에 ScreenLockActivity가 뜨지 않는다. ScreenLockActivity가 떠있는 상태에서 뒤로가기를 했을 때 생기는 치명적인 버그는 해결되었지만, 여전히 조금의 아쉬운 점이라고 볼 수 있겠다(그러나 이건 어쩔 수 없는 부분인건지, 아니면 이게 맞는 동작인건지 카카오톡 역시 동일하게 동작한다).

그래서 여기저기 문서를 찾아 보았다. 명쾌한 답을 얻지는 못했지만 나름 추측해 볼만한 문서가 있었다. App startup time - 공식문서를 보면 앱을 켤 때 항상 똑같은 속도로 앱이 켜지는게 아니라, Hot start 상태, Warm start 상태, Hot start 상태에 따라 속도가 다르다. 즉, 안드로이드에서는 앱을 다시 켜는 속도에 관한 정책이 있다는 것인데, 그렇다면 아마도 앱을 종료했다가 아주 빠르게 다시 앱을 키게 될 경우에 한해서는 실제로 메모리에서 앱을 종료하고 실행시키는 것이 비효율 적이라 판단하여 OnStart와 OnStop 라이프사이클을 실행시키지 않는게 아닐까라는 생각을 해볼 수 있었다. 지금 생각해보면 이게 사용성 측면에서 올바른 동작인 것 같기도 하다.

결론

ProcessLifecycleOwner를 사용하여 앱이 포그라운드에 진입할 때 마다 ScreenLockActivity를 실행시키되, ScreenLockActivityonBackPressed()에서 finishAffinity() 를 사용하지 말고, 단순히 홈 화면으로 이동시키는 코드를 넣자.

override fun onBackPressed() {
    moveTaskToBack(true)
}

Flow Context

|

이 포스팅은 공식문서를 보며 공부한 내용입니다.

Flow context

flow는 자신을 호출한 코루틴의 context에서 값을 발행하게 되어있습니다. 예를 들어, 아래의 foo 함수는 foo 의 구현과는 무관하게 foo를 호출하는 곳의 context에 따라 실행됩니다.

withContext(context) {
    foo.collect { value ->
        println(value) // run in the specified context
    }
}

이러한 특성을 context preservation, 즉 컨텍스트 보존이라고 부릅니다.

기본적으로 flow { ... } 빌더 안의 코드는 collet를 호출하는 측에서 제공하는 context로 실행됩니다. 아래에는 emit되는 숫자와 함께 쓰레드 정보를 찍는 예제가 있습니다.

fun foo(): Flow<Int> = flow {
    log("Started foo flow")
    for (i in 1..3) {
        emit(i)
    }
}

fun main() = runBlocking<Unit> {
    foo().collect { value -> log("Collected $value") }
}

결과는 아래와 같습니다.

[main @coroutine#1] Started foo flow
[main @coroutine#1] Collected 1
[main @coroutine#1] Collected 2
[main @coroutine#1] Collected 3

foo().collect 가 메인 쓰레드에서 호출되었기 때문에, foo flow의 코드블럭 역시 메인쓰레드로 호출됩니다.

Wrong emission withContext

그러나 시간이 오래걸리는 CPU작업은 Dispatcher.Default 컨텍스트에서 실행시키고, UI-update는 Dispatchers.Main 컨텍스트에서 실행시키는 경우가 많을거라 생각합니다. 보통같았으면 withContext를 사용하여 컨텍스트를 바꾸었을 겁니다. 그러나 flow { ... } 빌더는 컨텍스트 보존이라는 특성이 있기 때문에 다른 컨텍스트에서 값을 emit하는 것은 허용되어 있지 않습니다.

아래는 에러를 발생하는 코드입니다.

fun foo(): Flow<Int> = flow {
    // The WRONG way to change context for CPU-consuming code in flow builder
    kotlinx.coroutines.withContext(Dispatchers.Default) {
        for (i in 1..3) {
            Thread.sleep(100) // pretend we are computing it in CPU-consuming way
            emit(i) // emit next value
        }
    }
}

fun main() = runBlocking<Unit> {
    foo().collect { value -> println(value) }
}
Exception in thread "main" java.lang.IllegalStateException: Flow invariant is violated:
        Flow was collected in [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@5511c7f8, BlockingEventLoop@2eac3323],
        but emission happened in [CoroutineId(1), "coroutine#1":DispatchedCoroutine{Active}@2dae0000, DefaultDispatcher].
        Please refer to 'flow' documentation or use 'flowOn' instead
    at ...

flowOn operator

위의 에러로그를 보면 flowOn 함수가 언급되고 있습니다. flowOn 함수는 flow emission의 context를 바꾸는데 사용됩니다. 아래는 flowOn을 사용하여 context를 바꾸는 예시입니다.

fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        Thread.sleep(100) // pretend we are computing it in CPU-consuming way
        log("Emitting $i")
        emit(i) // emit next value
    }
}.flowOn(Dispatchers.Default) // RIGHT way to change context for CPU-consuming code in flow builder

fun main() = runBlocking<Unit> {
    foo().collect { value ->
        log("Collected $value")
    }
}

메인쓰레드에서 collection을 하는 동안, 백그라운드 쓰레드에서는 flow {...} 빌더 안의 코드들이 수행되게 됩니다.

주목할 점은, flowOn을 사용함으로써 기본적으로 하나의 코루틴이 emit과 collect를 순차적으로 하던 것을 각각의 코루틴이 동시에 수행하도록 바뀌었다는 점입니다. 이제 collection은 하나의 코루틴(“coroutine#1’)에서 일어나고, emission은 다른 스레드 위에서 돌아가는 또 다른 코루틴(“coroutine#2’)에 의해 동작합니다. 이 두가지 일은 동시에 일어나게 됩니다. 즉 flowOn 연산자는 context를 바꾸어야 할 때 새로운 코루틴을 생성하게 되는 것입니다.

Flow Exception

|

이 포스팅은 공식문서를 보며 공부한 내용입니다.

Flow Exception

Flow emitter 또는 flow 내부 연산 코드가 exception을 던질 경우 collection은 그 exception과 함께 종료되어버립니다. 이런 exception을 다루는 몇 가지 방법을 살펴봅시다.

Collector try and catch

collector 쪽에서 try/catch 블럭을 사용하여 excption을 간단히 처리할 수 있습니다.

fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        println("Emitting $i")
        emit(i) // emit next value
    }
}

fun main() = runBlocking<Unit> {
    try {
        foo().collect { value ->
            println(value)
            check(value <= 1) { "Collected $value" }
        }
    } catch (e: Throwable) {
        println("Caught $e")
    }
}
Emitting 1
1
Emitting 2
2
Caught java.lang.IllegalStateException: Collected 2

위 코드는 collect 연산자 코드에서 일어나는 exception을 잡아내고, exception이 발생한 이후의 값들은 emit되지 않음을 볼 수 있습니다. 물론 flow {...} 블럭에서 에러가 나더라도 collect 측의 catch가 잡아냅니다.

Everything is caught

위에 보았던 예제는 exception이 emitter에서 일어나든, 중간 연산자에서 일어나든, collect와 같은 terminal 연산자에서 일어나든지 상관없이 모든 exception을 잡아냅니다. 예를 들어 아래 예제를 보면 emit되는 값이 string으로 mapping되는 과정에서 exception을 일으키고, 이를 성공적으로 잡아내며 collection을 멈춥니다.

fun foo(): Flow<String> =
    flow {
        for (i in 1..3) {
            println("Emitting $i")
            emit(i) // emit next value
        }
    }
    .map { value ->
        check(value <= 1) { "Crashed on $value" }
        "string $value"
    }

fun main() = runBlocking<Unit> {
    try {
        foo().collect { value -> println(value) }
    } catch (e: Throwable) {
        println("Caught $e")
    }
}
Emitting 1
string 1
Emitting 2
Caught java.lang.IllegalStateException: Crashed on 2

Exception transparency

emit 하는 쪽에서 exception을 캡슐화 하고 싶으면 어떻게 할까요? 즉 collector 측에서는 예외처리를 하지 않고, emitter 측에서 에러를 감지하여 처리를 하려면 어떻게 해야 할까요?

모든 Flows 구현체는 exception에 투명해야합니다. flow {...} 빌더를 try/catch 블럭 안에서 사용하여 값을 emit하는 것은 exception에 투명하지 못한 행위입니다. exception에 투명하다는 말은, downstream에서 발생한 에러를 미리 처리하여 collector가 알 수 없게끔 되어서는 안된다는 의미이기 때문입니다. 에러가 났더라도 어떤 형태로든 collector가 알아차릴 수 있어야 합니다.

Roman Elizarov 블로그의 표현으로는, downstream에서 발생한 에러는 항상 collector로 전파되어야 한다고 나와있는데, 꼭 에러 객체가 그대로 전파되어야만 한다는 의미는 아닌것 같습니다. downstream에서 에러가 났을 때 collector가 어떤 방식으로도 전혀 몰라서는 안된다는 의미인 것 같습니다.

emitter는 catch 연산자를 통하여 exception transparency를 유지할 수 있고 exception 처리를 캡슐화 할 수 있습니다. catch 연산자 안에서 예외를 분석하여 어떤 예외가 포착되었는지에 따라 다른 방식으로 대응할 수 있습니다.

  • throw를 사용하여 throw 를 다시 던질 수 있습니다.
  • exception 상황이지만 평소처럼 emit 할 수 있습니다.
  • exception 을 무시하거나 단순히 로그를 찍거나 하는 등의 코드를 넣을 수 있습니다.

아래는 exception을 catch 하는곳에서 text를 emit하는 코드입니다.

foo()
    .catch { e -> emit("Caught $e") } // emit on exception
    .collect { value -> println(value) }

위의 코드와 같이 try/catch 없이도 예외처리가 가능합니다.

transparent catch

catch 는 중간 연산자로써 오직 Upstream에서 발생한 exception만 처리할 수 있습니다(catch 아래의 연산자에서 발생한 exception은 처리하지 못합니다). 즉 collect {...} 블럭에서 일어난 예외는 catch의 downstream에서 일어난 예외임으로 catch가 처리할 수 없습니다.

fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        println("Emitting $i")
        emit(i)
    }
}

fun main() = runBlocking<Unit> {
    foo()
        .catch { e -> println("Caught $e") } // does not catch downstream exceptions
        .collect { value ->
            check(value <= 1) { "Collected $value" }
            println(value)
        }
}
Emitting 1
1
Emitting 2
Exception in thread "main" java.lang.IllegalStateException: Collected 2
	at ....
	....

Catching declaratively

catch연산자를 사용하면 선언적으로 exception을 처리할 수 있기 때문에 매력적으로 보입니다. 따라서 모든 에러를 catch를 사용하여 선언적으로 다루고 싶은 욕심이 생기기 마련입니다. 어떻게 가능할까요? collect 연산자에 있는 코드들을 onEach 연산자로 옮기고 catch 연산자 앞쪽에 배치하면 가능합니다. flow가 발행하는 값의 Collection은 인자 없는 collect() 를 호출하여 실행시킵니다.

foo()
    .onEach { value ->
        check(value <= 1) { "Collected $value" }
        println(value)
    }
    .catch { e -> println("Caught $e") }
    .collect()
Emitting 1
1
Emitting 2
Caught java.lang.IllegalStateException: Collected 2

위의 결과를 보면 try/catch를 사용하지 않았음에도 성공적으로 “Caught…“를 확인할 수 있습니다.

변성(공변성 out, 반공변 in) 이해하기 1편 - 제네릭

|

이 글은 변성을 이해하기 위한 글이지만, 기초부터 닦기 위해 제네릭에 대한 설명도 포함한다. 제네릭을 완벽히 알고있다면, 변성(공변성 out, 반공변 in) 이해하기 2편 부터 봐도 무방하다.

변성이란?

타입이 있는 언어에는 변성이란 개념이 있다. 변성이란, (kotlin in action의 말을 인용하자면) List<String>List<Any>와 같이 기저 타입(List)가 같고, 타입 인자(String, Any)가 다른 여러 타입이 어떤 관계가 있는지 설명하는 개념이다. 예를 들어 방금 위에서 보았듯이 StringAny의 하위 타입이다(따라서 Any타입의 변수에 String 객체를 할당해도 괜찮다). 그렇다면 MutableList<String> 역시 MutableList<Any>의 하위 타입인가? 를 정의 하는 개념이다.

Generic부터 차근차근

변성은 제네릭 클래스를 사용하기때문에 나타나는 개념이다. 따라서 먼저 제네릭을 간단히 살펴보자.

제네릭이란

제네릭의 탄생 원인은 두 가지 관점에서 볼 수 있다.

  1. 형변환을 할 때 생기는 성능적인 이슈 해결
  2. 보다 더 안정적이고 범용적인 api 설계
  • 참고로 제네릭은 동적 타이핑 언어에만 있다. 동적 타이핑 언어인 파이썬이나 자바스크립트에서는 제네릭이라는 편의 장치가 없는데, 그 이유는 동적타이핑 언어의 경우 클래스나 함수를 만들 때, 즉 선언할 때 타입을 고려하지 않기 때문이다. 언제든 동적으로 타입이 변경되는 것을 허용하기 때문에 애초에 모든게 제네릭 한 요소들인 것이다. 반면 자바스크립트에 정적 타입을 지원하는 타입스크립트에서는 제네릭이 존재한다.

성능적인 목적

우선 예시로 사용할 Animal, Cat, Dog 클래스를 만들어 보았다.

abstract class Animal {
    abstract fun eat()
}
class Cat: Animal() {
    override fun eat() {
        println("야옹야옹")
    }
}
class Dog: Animal() {
    override fun eat() {
        println("멍멍")
    }
}

위에서 만든 동물 클래스에 먹이를 주는 feed()라는 함수를 만들어 보자. 함수 하나로 Cat이든 Dog든 상관없이 Animal이기만 하면 먹이를 줄 수 있게끔 범용적인 함수를 만들려고 한다.

fun feed(animal: Animal) {
    animal.eat()
}

위의 함수는 파라미터로 Animal을 받기 때문에 Cat이 오든, Dog가 오든 상관없이 먹이를 줄 수 있다. 이 코드가 그렇게 동작 할 수 있는 이유는 자동 형변환 연산이 있기 때문이다. cat, dog 객체를 넘기면 자동으로 Animal 타입으로 형변환 되어 Animal으로 간주되기에 eat()함수를 호출 할 수 있다. 아주 미미한 차이이긴 하지만, 이렇게 형변환 연산을 거치는 것은 분명히 거치지 않는 것보다는 런타임시 성능적인 저하를 일으킨다. 그렇기 때문에 이런 형변환을 최대한 줄여보기 위한 방안 중 하나로 나온 것이 제네릭이다.

그렇다면 제네릭을 사용해서 코드를 다시 작성해보자.

fun <T: Animal> feed(animal: T) {
    animal.eat()
}

fun main() {
    feed<Cat>(Cat()) // 야옹야옹
}

메인 함수를 보면, feed(Cat())처럼 사용할 수 있는데, 형변환이 일어나지 않는다. 제네릭 함수로 선언했기 때문에 feed함수는 컴파일 단계에서 아래와 같이 컴파일 된다고 한다.

fun feed(animal: Cat) {
    animal.eat()
}

런타임시 형변환이 일어나는게 아니라서 성능상 이슈가 없다.

안정적이고 범용적인 개발 목적

제네릭을 사용하면 안정적이고 범용적인 api 개발이 가능한데, 사실상 위에서 설명한 형변환과 맥락을 같이 한다.

제네릭 없이도 범용적인 api를 만들 수 있다(다만 안전하지 못하다). 제네릭을 사용한 범용 클래스와 제네릭을 사용하지 않고 만든 범용 클래스를 비교해보자.

먼저 제네릭을 사용한MyList라는 클래스를 만들어 보자(단지 List에서 필요한 기능만 가져다 쓰는 Wrapping 클래스이니 실용성은 없다. 예시로만 바라보자).

class MyList<T>() {
    private val myList: mutableListOf<T>()
    fun add(e: T) {
        myList.add(e)
    }
    fun get(index: Int): T {
        return myList.get(index)
    }
}

제네릭을 사용해서 어떤 타입의 객체가 오든 범용적으로 사용할 수 있는 MyList를 만들었다.

위 코드에서 타입 파라미터를 선언한 <> 부분을 지우고, T가 쓰인 곳을 모두 Any로 바꾸면 역시 범용적인 클래스가 된다.

class MyList() {
    private val myList = mutableListOf<Any>() // List도 같은 원리로 제네릭을 제거할 순 있지만, 이미 제공되는 클래스니 건들지 않겠다.
    fun add(e: Any) {
        myList.add(Any)
    }
    fun get(index: Int): Any {
        return myList.get(index)
    }
}

myList가 Any를 타입으로 받기 때문에, 어떤 객체든 올 수 있는 범용적인 api가 되었다. 다만, MyList의 get()을 사용하여 내가 add해주었던 객체를 꺼내올때는, 어떤 타입의 객체를 넣어주었는지에 따라서 매번 형변환을 해주어야 한다는 단점이 있다. 만약 String 타입을 add 했는데, 꺼내올때 실수로 Int형으로 형변환을 하면 런타임 에러가 나게됨으로 안전하지 못한 api다.

지금까지 제네릭에 대해 알아보았다. 정적 타입 언어에서는 제네릭을 사용함으로써 생기는 재미난 현상이 있다. 바로 변성이다. 조금 복잡하지만 알아두면 더 범용적인 api를 개발할 수 있다.

본젹적인 변성에 대한 내용은 변성(공변성 out, 반공변 in) 이해하기 2편 - 변성이란에서 알아보자.

Databinding without onCreateView - onCreateView is deprecated.

|

Fragment#onCreatView is deprected

Fragment#onCreatView가 API level 28부터 deprecated되었다.

이전까지 Fragment를 사용할 때 주로 사용했던 코드는 아래와 같았다.

class MyFragment : Fragment() {
    ...
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(layoutInflater, layoutId, null, false)
        return binding.root
    }
    ...
}

데이터 바인딩을 사용하지 않을 경우는 return 부분이 아래와 같았을 것이다.

return inflater.inflate(layoutId, container, false)

How to work without onCreateView

기본적으로 Fragment는 OnCreateView 없이도 동작할 수 있다. 다만 Fragment의 생성자로 layoutId를 넘겨줘야 한다.

class MyFragment : Fragment(R.fragment.my_fragment) {
    ...
}

데이터바인딩을 사용한다면, onViewCreated를 이용하면 된다.

class MyFragment : Fragment(R.fragment.my_fragment) {
    ...
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        binding = FragmentMyBinding.bind(view)
    }
    ...
}