올릴 빌드는 이미 만들어 둔 상태였다.
정식 출시에 쓸 AAB 를 서명까지 끝내 놓고 Play Console 의 출시 전 보고서를 다시 봤다. 직전 버전에 대한 보고서에 권장 조치가 두 줄 있었다. 하나는 「크기 조절 및 방향 제한 삭제」, 다른 하나는 「R8 최적화」. 권장일 뿐이지만 첫 프로덕션 빌드에 노란 줄을 달고 나가고 싶지는 않았다. 만들어 둔 빌드는 버리고 두 가지를 고친 뒤 버전 코드를 하나 올려 다시 빌드했다.
둘 다 Unity Player Settings 의 항목 하나씩이다. 방향은 Portrait 를 Auto Rotation 으로, R8 은 Minify 의 Release 에 체크. 문제는 그 체크가 실제로 무엇을 바꾸는지였다.
세로 고정이 왜 문제가 되나
「오늘 누가 쏘냐?」는 처음부터 세로 고정 게임이었다. 기준 해상도가 1080×1920 이고, 화면은 전부 코드로 만든 uGUI 로 그 세로 판 위에 올라간다. 빌드 설정을 강제하는 에디터 스크립트에는 defaultInterfaceOrientation = Portrait 가 박혀 있었고, 게임이 뜨자마자 실행되는 Bootstrap 도 한 번 더 Screen.orientation = Portrait 를 걸었다.
Unity 는 이 설정을 보고 AndroidManifest 의 액티비티에 screenOrientation="portrait" 를 쓴다. 폰에서는 아무 문제가 없다. 큰 화면에서는 다르다. 태블릿을 가로로 세워 둔 사람이 앱을 열면 세로 고정 앱은 화면 가운데에 세로 띠로 뜨고 양옆이 검게 남는다. Android 는 이걸 고치려고 큰 화면에서 앱이 걸어 둔 방향 고정을 점점 무시하는 쪽으로 가고 있고, Android 16 부터는 가장 짧은 변이 600dp 이상인 화면에서 방향 고정을 아예 듣지 않는다. Play 의 권장 조치는 그 흐름을 미리 알려 주는 것이었다.
그런데 이 게임은 폰에서 세로 고정이어야 했다. 여럿이 폰 하나를 돌려 가며 하는 게임이라, 손에서 손으로 넘어가는 동안 폰은 계속 기울어진다. 기울일 때마다 화면이 돌아가면 게임이 망가진다. 매니페스트에서 고정을 풀어야 하는데, 폰에서는 고정이 필요했다.
매니페스트는 풀고, 고정은 실행 중에
답은 고정하는 자리를 옮기는 것이었다. 매니페스트에는 아무 제한도 두지 않고, 앱이 실행된 뒤에 화면 크기를 보고 스스로 방향을 정한다.
이 프로젝트에는 커스텀 AndroidManifest 도, Gradle 템플릿도 없다. Assets/Plugins/Android 폴더 자체가 없다. 빌드 설정은 전부 에디터 스크립트가 PlayerSettings 로 넣고, 매니페스트와 Gradle 프로젝트는 Unity 가 그때그때 만든다. 그래서 바꾼 곳도 에디터 스크립트였다.
// 매니페스트는 방향을 고정하지 않는다. 폰 세로 고정은 실행 중에 CanvasFitter 가 한다
PlayerSettings.defaultInterfaceOrientation = UIOrientation.AutoRotation;
PlayerSettings.allowedAutorotateToPortrait = true;
PlayerSettings.allowedAutorotateToPortraitUpsideDown = true;
PlayerSettings.allowedAutorotateToLandscapeLeft = true;
PlayerSettings.allowedAutorotateToLandscapeRight = true;
// R8 코드 축소. 매핑 파일은 AAB 안에 자동으로 들어간다
PlayerSettings.Android.minifyRelease = true;
Auto Rotation 에 네 방향을 모두 열면 Unity 는 매니페스트에 fullUser 를 쓴다. 센서를 따라 네 방향으로 돌되 사용자가 시스템에서 자동 회전을 꺼 두면 그 설정을 따르는 값이다. 다시 만든 AAB 의 매니페스트를 열어 보니 이전 빌드의 portrait 자리에 fullUser 가 들어가 있었다. configChanges 에는 원래부터 orientation|screenSize|smallestScreenSize 가 들어 있어서, 화면이 돌거나 폴더블을 펼쳐도 액티비티가 다시 만들어지지 않고 Unity 가 그대로 해상도 변경만 받는다.
실행 중의 규칙은 화면 비율을 맞추던 CanvasFitter 에 넣었다. 이 컴포넌트는 폴더블을 접고 펴는 순간을 잡으려고 매 프레임 화면 크기를 보다가 바뀌면 다시 계산한다. 방향 규칙을 다시 정해야 하는 순간도 정확히 그때다.
static void ApplyOrientation()
{
#if UNITY_ANDROID && !UNITY_EDITOR
bool rotate = SmallestWidthDp() >= LargeScreenDp; // 600
if (_rotate == rotate) return;
_rotate = rotate;
if (rotate)
{
Screen.autorotateToPortrait = true;
Screen.autorotateToPortraitUpsideDown = true;
Screen.autorotateToLandscapeLeft = true;
Screen.autorotateToLandscapeRight = true;
Screen.orientation = ScreenOrientation.AutoRotation;
}
else Screen.orientation = ScreenOrientation.Portrait;
#endif
}
기준은 지금 화면의 가로세로 픽셀이 아니라 Android Configuration 의 smallestScreenWidthDp 다. 픽셀은 회전할 때마다 가로세로가 뒤바뀌고 기기마다 밀도가 달라 기준으로 쓸 수 없다. 가장 짧은 변의 dp 는 회전해도 그대로이고, Android 가 큰 화면을 가르는 기준도 이 값의 600 이다. Unity 에는 이 값을 주는 API 가 없어서 AndroidJavaClass 로 UnityPlayer.currentActivity 를 잡고 getResources().getConfiguration() 에서 필드를 직접 읽었다. 읽다가 실패하면 0 을 돌려준다. 모르면 폰으로 보고 세로로 묶는 쪽이 안전하다.
_rotate 는 마지막으로 정한 규칙이다. 화면 크기는 회전할 때마다 바뀌니 Apply 는 자주 불리는데, 규칙이 바뀌었을 때만 Screen.orientation 을 건드린다. 그리고 Bootstrap 에 있던 Screen.orientation = Portrait 는 지웠다. 방향을 정하는 곳이 두 군데면 언젠가 둘이 싸운다. 이제 Screen.orientation 을 바꾸는 코드는 이 함수 하나뿐이다.
결과는 이렇다. 폰은 매니페스트상으로는 자유지만 실행되자마자 세로로 묶인다. 태블릿과 폴더블의 펼친 화면은 네 방향으로 돈다. 폴드를 접으면 짧은 변이 600dp 아래로 내려가니 그 순간 다시 세로로 묶이고, 펴면 풀린다.
가로가 된 세로 게임
태블릿에서 돌기 시작하면 세로 1080×1920 으로 짠 화면이 가로 화면에 올라간다. 여기서 UI 가 버티느냐가 다음 걱정이었다.
캔버스는 CanvasScaler 의 Scale With Screen Size, 기준 1080×1920 이다. CanvasFitter 는 화면 비율이 기준(9:16)보다 넓으면 matchWidthOrHeight 를 1, 길면 0 으로 바꾼다. 원래는 펼친 폴드에서 화면 아래가 잘리던 문제를 고치려고 만든 규칙이다. 가로 화면은 9:16 보다 훨씬 넓은 화면일 뿐이니 같은 규칙이 그대로 적용된다. 높이를 맞추면 캔버스의 높이는 항상 1920 이고, 16:10 태블릿을 눕히면 캔버스 너비가 3000 남짓이 된다. 기준 영역 1080×1920 은 가운데에 통째로 들어가고, 남는 건 양옆이다.
그다음은 각 요소가 그 넓어진 캔버스의 어디에 붙느냐다. UI 를 전부 코드로 만들면서 거의 모든 요소를 위 가운데, 가운데, 아래 가운데 앵커에 붙였다. 가로 좌표가 전부 0.5 앵커에서 출발하고 폭은 1040 을 넘지 않으니, 캔버스가 아무리 넓어져도 내용은 가운데 세로 판 안에 모인다. 왼쪽·오른쪽 앵커를 쓰는 곳도 있긴 한데 순위표의 한 줄처럼 폭이 정해진 행 안에서만이다. 행이 가운데 있으니 그 안의 왼쪽도 가운데 판의 왼쪽이다.
양옆은 비어 보이지 않는다. 배경 이미지는 캔버스 전체에 늘린 이미지라 가로 끝까지 같은 색으로 깔리고, 결과 화면이나 오버레이의 어두운 막도 부모를 가득 채우는 stretch 앵커로 만들어서 화면 끝까지 덮는다. 가로 태블릿에서는 세로 게임 화면이 가운데 서 있고 양옆은 배경색으로 이어진다.
가로 전용 레이아웃은 만들지 않았다. 태블릿을 눕혀 쓰든 세워 쓰든 잘리거나 겹치지 않으면 된다. 권장 조치가 바라는 것도 그것이었다. 회전을 막지 말고, 돌렸을 때 망가지지 말 것.
R8 은 문자열을 모른다
R8 쪽은 체크 하나가 Gradle 에서 무엇이 되는지부터 봤다. Unity 가 만든 Gradle 프로젝트의 launcher 모듈을 열면 release 빌드 타입에 minifyEnabled true 와 proguard-android-optimize.txt 가 들어가 있다. 여기에 unityLibrary 모듈이 소비자 규칙으로 proguard-unity.txt 를 얹는다. 직접 쓴 규칙 파일은 없다.
R8 은 쓰이지 않는 클래스를 지우고, 남은 클래스와 메서드의 이름을 짧게 바꾼다. 쓰이는지 아닌지는 Java 코드의 참조를 따라가서 판단한다. 그런데 이 게임이 Android 기능을 부르는 방식은 Java 참조가 아니다. 진동, 결과 이미지 공유, 같이 하기의 멀티캐스트 잠금, 초성 퀴즈의 음성 입력까지 전부 C# 에서 AndroidJavaClass("com.unity3d.player.UnityPlayer") 처럼 클래스 이름을 문자열로 넘겨 JNI 로 찾는다. IL2CPP 가 만든 네이티브 코드 안의 문자열은 R8 이 볼 수 없다. R8 입장에서는 아무도 안 쓰는 클래스다. 깨진다면 여기서 깨진다.
그래서 C# 이 이름으로 부르는 클래스를 하나씩 따져 봤다. 대부분은 android.content.Intent, android.provider.MediaStore, android.os.VibrationEffect, android.speech.SpeechRecognizer 같은 프레임워크 클래스다. 이건 앱의 dex 가 아니라 기기의 OS 안에 있으니 R8 이 손댈 수 없다. 음성 입력의 콜백은 AndroidJavaProxy 로 android.speech.RecognitionListener 를 구현하는데, 이 인터페이스도 프레임워크 것이고 프록시는 실행 중에 만들어진다. 공유도 androidx 의 FileProvider 대신 MediaStore 를 쓰고 있어서 걸리는 게 없었다.
남는 건 앱 dex 안에 있는 com.unity3d.player.UnityPlayer 하나다. 이건 Unity 의 규칙이 지킨다.
-keep class com.unity3d.player.* { *; }
-keep interface com.unity3d.player.IUnityPlayerLifecycleEvents { *; }
-keep class org.fmod.* { *; }
빌드가 끝나면 Unity 가 매핑 파일을 빌드 폴더에 함께 남긴다. 거기서 com.unity3d.player.UnityPlayer -> com.unity3d.player.UnityPlayer 를 확인했다. 이름이 그대로다. 같은 파일에서 재미있는 줄도 봤다. com.unity3d.player.a.A -> k0.a. 규칙이 .** 가 아니라 .* 라서 패키지 바로 아래 클래스만 지키고, 하위 패키지의 내부 클래스는 이름이 바뀐다. 바깥에서 이름으로 부르지 않는 클래스라 괜찮은 것이다.
무사했던 건 직접 만든 Java 플러그인이 하나도 없어서다. .aar 나 Java 파일을 넣고 C# 에서 이름으로 부르는 순간, 그 클래스에는 -keep 을 직접 걸어야 한다. 이번에는 그런 클래스가 없다는 걸 확인하는 것으로 충분했다.
마지막 확인은 갤럭시 Z 플립3 에서 했다. 액티비티의 요청 방향이 PORTRAIT 로 잡혀 있는지 보고, 음성 입력으로 초성 퀴즈 정답을 말하고, 결과 이미지를 공유하고, 진동을 받았다. 모두 정상이었고 로그에 새 오류는 없었다.
6.4MB 가 0.8MB 가 됐다
권장 조치 전후의 AAB 를 열어 비교했다.
classes.dex 가 약 6.4MB 에서 약 0.8MB 로 줄었다. 매핑 파일을 보면 이유가 보인다. 거기 오른 클래스의 상당수가 androidx.appcompat, androidx.core, androidx.fragment, kotlinx.coroutines 같은 라이브러리다. Unity 의 GameActivity 가 끌고 오는 의존성인데, 이 게임은 그중 극히 일부만 쓴다. 나머지는 통째로 지워졌고 남은 것도 이름이 한두 글자로 줄었다. AAB 에 같이 들어간 R8 통계로는 난독화에서 빠진 비율이 31% 남짓이다.
다만 AAB 파일 전체는 약 25.0MB 에서 23.5MB 로 1.5MB 남짓 줄었을 뿐이다. 두 가지 이유가 있다. 하나는 크기의 대부분이 Java 가 아니라는 것이다. libil2cpp.so 가 약 29MB, libunity.so 가 약 19MB 로 압축 전 기준 둘이 거의 전부이고, R8 은 이쪽에 손대지 않는다. 다른 하나는 줄어든 만큼 새로 들어간 게 있다는 것이다. BUNDLE-METADATA 아래에 4.2MB 짜리 proguard.map 이 붙었다. 이 파일은 사용자 기기로 내려가지 않고, Play 가 비정상 종료 보고의 Java 스택을 원래 이름으로 되돌리는 데 쓴다. AAB 안에 있으니 따로 올릴 필요도 없었다.
사용자가 내려받는 쪽에서는 매핑 파일은 빠지고 dex 가 줄어든 몫만 남는다. 네이티브 라이브러리에 비하면 작지만, 체크 하나로 쓰지 않는 코드 5MB 남짓이 사라졌다.
체크 하나의 뒤편
겉으로 보면 Player Settings 에서 바꾼 건 방향 하나와 체크박스 하나다.
방향은 매니페스트의 고정을 실행 중으로 옮기는 일이었고, 폴더블 때 만든 화면 맞춤과 가운데 앵커 습관이 가로 화면까지 받아 냈다. R8 은 C# 이 문자열로 부르는 Java 클래스를 세어 보는 일이었다. 둘 다 고친 코드보다 확인한 것이 더 많았다.
다음 편은 빌드 바깥, 스토어에 적는 것들이다. 광고 선언과 대상 연령, 그리고 등록정보에 넣지 않기로 한 문장들.