몇일전 기능 구현후 테스트 하는 과정에서 Font 관련 함수에서 특정한 상황에 크래시가 나는 상황을 경험했습니다.
멋진 팀원들의 도움을 받아 어떤식으로 해결하였는지 공유합니다.
크게
1. 왜 크래시가 났는지?
2. 왜 간헐적으로 났는지?
3. 크래시가 나지 않았을 때는 어떤 클래스에서 이를 해소해주고 있었는지?
에 대한 내용으로 구성됩니다.
문제가 생긴 함수는 아래와 비슷한 식의 Font 의 id 를 가져와서 적용해주는 코드였습니다.
// 예시 함수
fun TextView.setFont(@FontRes fontRes: Int) = apply {
typeface = ResourcesCompat.getFont(context, fontRes)
}
크래시 재현 상황은 앱 시작하자마자 특정 화면으로 이동하거나, 딥링크 등을 통해 다이렉트로 이동하면 ResourceCompat 의 loadFont 함수에서 Font resource ID 를 찾을 수 없다는 에러를 던지고 있었습니다.
회사에서는 직접 만든 Qanda Font(줄여서 "콴폰"이라고 하겠습니다) 를 서버에서 다운 받아 폴더에 저장하고 사용하고 있기 때문에, CustomFontProvider 를 통해 Font 를 다운받고 완료되었을 때 Font 를 적용하도록 처리 되어야 합니다.
해야하는 프로세스를 간략하게 정리하면,
1. "콴폰" xml 정의
2. CustomFontProvider 를 통해 "콴폰"이 필요하다고 Query 시작
3. Query 가 시작되면 서버를 통해 "콴폰" 다운로드 진행
4. "콴폰" 다운로드가 완료되면 원하는 TextView 에 Font 를 적용해준다.
정도가 될 것 같습니다.
그리고 "콴폰" 을 다운 받는 부분까지는 문제 없이 진행되었다고 가정합니다. 3번까지의 과정이 궁금하다면 Github 에서 제가 다니는 회사를 검색하여 잘 찾아보시면 제공하고 있습니다 :)
하지만 위의 문제가 되는 코드에서 사용하고 있는 ResourcesCompat getFont 함수 내부를 보면, fontCallback 을 null 로 보내주고 있는데, ResourceCompat loadFont 를 보면 type == null 이면서 fontCallback == null 이면서 isCachedOnly(Cached 된 font 에서만 가져올 것인지 체크) 가 false 인 경우 에 NotFoundException 을 던지게 되어있는데 fontCallback 에는 null isCachedOnly 는 false 를 넘기고 있기 때문에 typeface == null 해당 조건으로만 판단하게 되어있습니다.
따라서 Font 가 없다면 비동기적으로 다운받아 적용해주는 것이 아닌, Resource.getFont 함수를 호출할 때, 당시에 Font 가 저장되어 있지 않으면 결과적으로 NotFoundException 을 던지게 됩니다.
원인을 찾음으로써 왜 간헐적으로 크래시가 났는지도 알게되었습니다.
해당 크래시가 나는 화면을 처음으로 가지 않고, 다른 화면(크래시가 나는 화면에서 사용하는 Font 가 있는 화면)들을 이용하다가 오게 되면 이미 Font 를 다운 받아져 있기 때문에 Memory Cache(LRU) 에서 찾아서 사용하고 있음을 찾을 수 있었습니다.
sTypeFaceCache 기본 값으로는 최대 16개로 되어 있었고 따로 maxSize 를 변경하지 않았기 때문에 16개 이상이 넘으면서 최근에 사용된 적이 없게 된다면 문제가 생길 수 있다는 것도 알게 되었습니다.
그러면서 문득 생각이 들었습니다.
결국은 다른 화면도 첫번째로 들어가는 순간이 있고 그때마다 크래시가 나야하는데, 나지 않는 이유가 있을텐데 어떤 클래스가 그런 작업을 해주고 있는 것인가 ?
이미 경험적으로 알고 계신 팀원도 계셨고, 그 이유에 대해 명확하게 알고 계신 분도 계셨습니다
핵심은 TextAppearance 클래스에 있었습니다. 회사에서 Xml 에서 TextView 를 사용할 때는 대부분
android:textAppearance="?textAppearanceBody..."
style=@style/CustomStyle....
와 같은 방법으로 사용하거나, 직접 코드에서 사용하는 경우
val type = TypedValue()
context.theme.resolveAttribute(R.attr.textAppearanceBody..., type, true)
TextViewCompat.setTextAppearance(this, type.resourceId)
와 같은 식으로 많이 사용하고 있습니다.
그리고 추가적으로 Xml 에서 TextView 를 사용하게 되면 테마가 무엇이냐에 따라 MaterialComponentsViewInflater(AppCompatViewInflater) 가 호출되고 해당 클래스의 구현체인 AppCompatDelegateImpl 의 createView 가 호출되면서 그 안에서 TextView 를 AppCompatTextView 로 변경 하고 있습니다. (이부분은 드로이드 나이츠 2021 Pluu 님의 영상 강추 합니다)
이제 거의 다 왔습니다.
결과적으로 AppCompatTextView 의 어디에서 Font 를 비동기적으로 설정해주고 있는지만 알아내면 됩니다.
내부에 mTextelper(AppCompatTextHelper Class) 라는 것을 가지고 있습니다.
updateTypeFaceAndStyle 함수 이름에서 왠지 해주고 있지 않을까 라는 느낌이 강력하게 느껴집니다.
replayCallback 이라는 FontCallback 을 만들고 있습니다. 거의 이제 확신으로 다가 옵니다..!
만들어진 replayCallback 을 getFont 함수에 넘겨주고 있습니다.
내부에서 ResourcesCompat.getFont 를 호출하면서 fontCallback 을 다시 넘겨주고 있네요.
내부에서 loadFont 함수를 통해 typeface 를 반환해주고 있습니다. 아니라면 NotFoundException 처리를 해주는 곳도 보이네요.
함수 하나만 더 보면 확실한 결론을 알 수 있을 것 같습니다.
아래의 함수가 마지막 입니다.
저희가 생각했던 기능들이 모두 들어있습니다.
findFromCache 를 통해 캐시가 히트 하면 fontCallBack.callbackSuccessAsync 메서드를 통해 알려주고 있습니다.
xml 에서 호출되었다면, FontResourceParserCompat 을 통해 familyEntry 를 만들고 TypeCompat.createFromResourcesFamilyXml 메서드를 사용하여 바로 typeface 를 반환하고 있습니다.
이제 정말로 기대하고 있던 코드가 나오는 부분입니다
위의 조건들도 아니라면, TypefaceCompat.createFromResourcesFontFile 즉 FontFile 을 통해 typeface 를 가져오고, fontCallBack.callbackSuccessAsync 를 통해 결과를 알려주고 있습니다.
비로소 확인할 부분은 모두 확인했고, 이번 고군분투를 하며 느낀점이 있다면,
ResourcesCompat 의 getFont 함수에는 동기적으로, 비동기적으로 사용할 수 있도록 제공하고 있습니다. 따라서 setTextAppearance 를 사용하기 어려운 상황이나 혹은 사용하지 않고, getFont 메서드를 직접 사용하여 Font 를 적용할 수도 있습니다.
이것으로 글을 마치고 도움을 주신 팀원분들에게 감사함을 전합니다.
이번 사건이 앞으로 내부 코드를 더욱 자주 보게 되는 계기가 되었으면 좋겠습니다.
'Android > Today I Learned' 카테고리의 다른 글
Compose 버튼 사이즈 관련 Tips (1) | 2022.03.01 |
---|---|
Compose Navigation - viewmodel 사용할 때 주의할 점 (0) | 2021.12.25 |
Coil 로 OnDemand-image-resizing 적용하기 (0) | 2021.11.23 |
Glide cache 제거 하기 (0) | 2021.11.21 |
멀티 모듈에서 Missing required view with ID 에러 해결 (1) | 2021.06.07 |