dispatchDetachedFromWindow NullPointerException: 스택에 내 코드가 없는 안드로이드 크래시 추적기
스택 트레이스에 프레임워크 코드만 찍히는 NullPointerException 을 MdViewer 에서 만났습니다. onDetachedFromWindow 안에서 형제 뷰를 뗀 한 줄이 원인이었고, 부모 ViewGroup 이 자식 배열을 도는 방식 때문에 생긴 문제였습니다. 재현, 원인, 고친 방법을 정리합니다.
크래시 메일 두 통
9월 30일, Crashlytics 에 MdViewer 1.0.37 크래시 이슈가 두 건 올라왔습니다. 기기는 둘 다 갤럭시 S23(Android 16)이었고 메시지는 같았습니다.
java.lang.NullPointerException: Attempt to invoke virtual method
'void android.view.View.dispatchDetachedFromWindow()' on a null object reference
at android.view.ViewGroup.dispatchDetachedFromWindow(ViewGroup.java)
at android.view.ViewGroup.dispatchDetachedFromWindow(ViewGroup.java)
...
곤란한 건 스택에 제 코드가 한 줄도 없다는 점이었습니다. 프레임워크 프레임만 이어지다가, R8 인라인 때문에 엉뚱하게 MainActivity.onCreate 가 하나 끼어 있을 뿐이었습니다. 「어떤 뷰가 null 이냐」를 스택으로는 알 수 없었습니다.
어디서 찾았나
dispatchDetachedFromWindow 가 null 자식을 만났다는 건, 떼는 도중에 누군가 뷰 트리를 바꿨다는 뜻입니다. 그래서 스택 대신 코드에서 「떼어질 때 실행되는 곳」을 전부 찾았습니다.
onDetachedFromWindow
OnAttachStateChangeListener
doOnDetach
걸린 곳은 1.0.36 에서 문서 화면을 RecyclerView 기반으로 바꾸며 새로 만든 SegmentsView 였습니다.
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
dropMermaid() // 형제인 mermaidHost 를 부모에서 removeView
}
MdViewer 는 mermaid 다이어그램을 WebView 로 그립니다. 이 WebView 를 담은 mermaidHost 를 문서 뷰의 형제로, 부모 CoordinatorLayout 의 0번 자리에 끼워 두었습니다. 문서 뷰가 떨어질 때 같이 정리하려고 onDetachedFromWindow 에서 떼게 한 것이 원인이었습니다.
왜 null 이 되나
부모 ViewGroup 은 자식들을 뗄 때 이렇게 돕니다(AOSP 요약).
final int count = mChildrenCount;
final View[] children = mChildren;
for (int i = 0; i < count; i++) {
children[i].dispatchDetachedFromWindow();
}
개수 count 와 배열 children 을 루프 전에 잡아 둡니다. 그런데 루프 안에서 불린 제 onDetachedFromWindow 가 형제를 removeView 하면, 부모의 자식 배열이 한 칸씩 앞으로 당겨지고 마지막 칸은 null 이 됩니다. 루프는 이미 잡아 둔 count 대로 끝까지 가므로 마지막 칸에서 null 을 만나 NPE 가 납니다.
그래서 조건이 좁았습니다. mermaid 다이어그램이 있는 문서를 연 상태에서 뷰어를 나가거나(프래그먼트가 뷰를 뗌), 다크 모드 전환처럼 화면이 다시 만들어질 때만 터졌습니다. 다이어그램이 없는 문서에서는 mermaidHost 자체가 없어서 아무 일도 없었습니다. 그리고 조건을 맞추면 100% 재현됐습니다.
고친 방법
떼는 일을 부모 순회가 끝난 뒤로 미뤘습니다.
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
val m = mermaid
val h = mermaidHost
mermaid = null
mermaidHost = null
if (m != null || h != null) Handler(Looper.getMainLooper()).post {
m?.destroy()
h?.let { (it.parent as? ViewGroup)?.removeView(it) }
}
}
View.post 가 아니라 Handler(Looper.getMainLooper()).post 를 쓴 데는 이유가 있습니다. 이미 떨어진 뷰에서 View.post 를 부르면, 그 작업은 다음에 다시 붙을 때까지 뷰 안의 대기열에 머뭅니다. 뷰어를 완전히 나가는 경우엔 다시 붙을 일이 없으니 WebView 가 정리되지 않고 남습니다. 메인 핸들러에 올리면 현재 순회가 끝나자마자 실행됩니다.
검증
순서를 지켰습니다. 먼저 수정 전 빌드로 크래시를 재현하고, 같은 절차로 수정 후 빌드를 확인했습니다. 갤럭시 노트10+ 에서 mermaid 가 들어 있는 문서를 연 뒤 다음을 해 봤습니다.
- 앱 안 뒤로 버튼으로 뷰어 나가기
- 시스템 뒤로 가기
- 다크 모드 전환으로 화면 재생성 2회
- 같은 문서 다시 열기
수정 전 빌드에서는 크래시가 그대로 재현됐고, 수정 후에는 네 가지 모두 크래시가 없었습니다. 이 수정은 MdViewer 1.0.38 에 들어갔습니다.
정리
onDetachedFromWindow안에서는 자기 자신 정리만 합니다. 형제나 부모의 다른 자식을 떼면 부모가 순회 중인 배열이 흔들립니다.- 스택에 내 코드가 없는 뷰 크래시는 스택 대신
onDetachedFromWindow·doOnDetach같은 「떼어질 때」 훅을 grep 하는 편이 빠릅니다. - 떼는 정리를 미룰 때는
View.post말고 메인Handler로 올립니다. - 1.0.36 에서 화면 구조를 바꿀 때 「mermaid 문서를 연 채 나가기」를 검증하지 않아서 놓쳤습니다. 뷰 구조를 바꾼 뒤에는 특수한 내용이 든 화면에서 나가기·재생성까지 해 봐야 합니다.
이 글에 나온 앱은 안드로이드 마크다운 뷰어 MdViewer(Google Play) 입니다.