jetpack compose skippable issue 분석하기 - metrics

이태훈·2022년 4월 20일
1

이전에 Multi Module에서 Compose Compiler가 없는 모듈의 클래스를 가져와 사용할 때, Skippable이 의도대로 적용되지 않는 점을 포스팅한 적이 있습니다.

이번 포스팅에서는 그러한 것들을 간편히 데이터로 확인할 수 있는 방법에 대해 알아보고, 해당 자료를 분석해보겠습니다.

Metrics 생성

compose를 적용한 모듈 build.gradle에 다음과 같은 코드를 추가하면 됩니다.


kotlinOptions {
	freeCompilerArgs += ["-P",
						"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${rootProject.file(".").absolutePath}/compose-metrics"]
	freeCompilerArgs += ["-P", 
    					"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${rootProject.file(".").absolutePath}/compose-reports"]
}

저는 이전에 포스팅한 프로젝트에서 빌드를 돌렸습니다.

그런 다음 빌드를 돌리면 아래와 같은 폴더와 파일이 생성됩니다.

project
│
└───compose-metrics
│   │   app_debug-module.json
└───compose-reports
    │   app_debug-classes.txt
    │   app_debug-composables.csv
    │   app_debug-composables.txt

Files

app_debug-module.json

{
 "skippableComposables": 6,
 "restartableComposables": 7,
 "readonlyComposables": 0,
 "totalComposables": 7,
 ...
}

제일 먼저 눈에 띄는 파일을 보겠습니다. 이 파일들을 이해하기 위해 다음과 같은 개념을 이해해야 합니다.

  • SkippableComposables
  • RestartableComposables

이 두 Composable에 대해 알아보기 위해 이 블로그의 code snippet을 통해 알아보겠습니다.

Restartable Composables

restartableComposables란, Snapshot Object의 상태 변경으로 인한 Recomposition이 일어날 때 재실행될 수 있는 Composable 함수의 갯수를 의미합니다.

Snapshot이란 Jetpack Compose에서 사용되는 State Class Holder를 의미합니다.
Jetpack Compose는 상태 변화를 감지하기 위해 Snapshot System을 사용합니다.
정확한 내용은 다음 이 블로그를 봐주시기 바랍니다.

Recomposition을 발생시키는 Compose Compiler가 생산한 코드를 보면 다음과 같습니다.

@Composable
fun Counter() {
	var count by remember { mutableStateOf(0) }
	Button(
		onClick = { count += 1 }
	) {
		Text(text = "Count : $count")
	}
}

위와 같은 간단한 Counter 코드는 Compose Compiler가 다음과 같이 변환합니다.

@Composable
public static final void Counter(@Nullable Composer $composer, final int $changed) {

	$composer = $composer.startRestartGroup(741257999);
    
    ...
    
    if ($changed == 0 && $composer.getSkipping()) {
    	$composer.skipToGroupEnd();
    
    	...

	} else {
    	$composer.startReplaceableGroup(-492369756);
        
        ...
        
        $composer.endReplaceableGroup();
        ButtonKt.Button(
        	...,
            $composer,
            ...
        )
    }
    
    ScopeUpdateScope var14 = $composer.endRestartGroup();
    if (var14 != null) {
    	var14.updateScope((Function2)(new Function2() {
            public final void invoke(@Nullable Composer $composer, int $force) {
               SimpleComposeSampleKt.Counter($composer, $changed | 1);
            }
         }));
     }
}

변환된 코드에서 composer의 endRestartGroup에서 반환된 값이 널이 아닐 경우 해당 값을 observing하여 State가 변경됐는지 감지하고 Recomposition을 수행하게 됩니다.

통상적으로 이런 Observing을 우리가 Composable 함수 안에서 따로 작성을 해주지 않습니다.

Compose Compiler에서 알아서 옵저빙을 하는 방식이 바로 Snapshot System입니다.

Snapshot

간단한 MutableSnapshot을 사용하는 예제를 보겠습니다.

MutableSnapshot의 인자로 readObserver, writeObserver를 넣어주게 되는데, 이 두 옵저버를 통해 State의 변화를 감지합니다.

fun main() {
	val state = mutableStateOf("first")
	val snapShot = Snapshot.takeMutableSnapshot(
		readObserver = { println("The State is Read : $it") },
		writeObserver = { println("The State is OverWritten : $it") }
	)
	snapShot.enter {
		state.value
		state.value = "second"
	}
	snapShot.apply()
}

위의 함수의 출력은 다음과 같습니다.

The State is Read : MutableState(value=first)@189568618
The State is OverWritten : MutableState(value=second)@189568618

이렇게 Snapshot System에서 State의 변화를 감지하여 Recomposition의 예제의 코드와 같이 state의 변화를 감지해 Recomposition을 하게 됩니다.

더 자세히 알아보기 위해 코드를 타고 들어가보겠습니다.

  • 추후 보완 예정
var14.updateScope((Function2)(new Function2() {
	public final void invoke(@Nullable Composer $composer, int $force) {
    	SimpleComposeSampleKt.Counter($composer, $changed | 1);
    }
}));

override fun updateScope(block: (Composer, Int) -> Unit) { this.block = block }

fun compose(composer: Composer) {
	block?.invoke(composer, 1) ?: error("Invalid restart scope")
}

Skippable Composables

skippableComposables란, Composable 함수의 인자가 이전과 동일할 때 skip이 발생할 수 있는데, 이런 것이 가능함 Composable 함수의 갯수를 의미합니다.

마찬가지로 간단한 코드를 보겠습니다.

@Composable 
fun Google(number: Int) {
 Address(
   number=number,
   street="Amphitheatre Pkwy",
   city="Mountain View",
   state="CA"
   zip="94043"
 )
}

위의 코드의 변환 코드입니다.

@Composable
public static final void Google(final int number, @Nullable Composer $composer, final int $changed) {
	$composer = $composer.startRestartGroup(-1476322213);
     
     ...
      
    int $dirty = $changed;
    if (($changed & 14) == 0) {
        $dirty = $changed | ($composer.changed(number) ? 4 : 2);
    }

	if (($dirty & 11) == 2 && $composer.getSkipping()) {
        $composer.skipToGroupEnd();
    } else {
        Address(
        	number,
         	LiveLiterals$SimpleComposeSampleKt.INSTANCE.String$arg-1$call-Address$fun-Google(), 
            LiveLiterals$SimpleComposeSampleKt.INSTANCE.String$arg-2$call-Address$fun-Google(), 
            LiveLiterals$SimpleComposeSampleKt.INSTANCE.String$arg-3$call-Address$fun-Google(), 
            LiveLiterals$SimpleComposeSampleKt.INSTANCE.String$arg-4$call-Address$fun-Google(),
            $composer,
            14 & $dirty
        );
    }
      
      ...
 
 }

Skippable 부분의 코드만 보여드리겠습니다.

$changed라는 static int 데이터의 bit 연산을 통해 인자의 값이 바뀐지 확인하여 스킵할 수 있는지 없는지 판단합니다.

이러한 인자의 값은 Compose Compiler가 Slot Table에 데이터를 저장하게 되고, $composer.changed 에서 slot table에서 데이터를 꺼내옵니다.
slot table에서 꺼내온 데이터를 비교하여 스킵을 해도 되는지 체크를 하는 방식으로 진행됩니다.

Metrics File with Project Code

다시 metrics를 보겠습니다.

app_debug-module.json

{
 "skippableComposables": 6,
 "restartableComposables": 7,
 "readonlyComposables": 0,
 "totalComposables": 7,
 ...
}

app_debug-composables.txt

restartable scheme("[androidx.compose.ui.UiComposable]") fun UserProfile(
  unstable user: User
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun WrappedUserProfile(
  stable userUiState: UserUiState
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun OtherContent(
  stable otherState: Int
)

app_debug-composables.csv

namecomposableskippablerestartable
UserProfile101
WrappedUserProfile111
OtherContent111

해당 데이터가 발생된 코드를 보면 다음과 같습니다.

class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)

		setContent {
			val viewModel: MainViewModel = viewModel()
			val otherState by viewModel.otherState.collectAsState()
			val userState by viewModel.wrappedUser.collectAsState()
			val user by viewModel.user.collectAsState()

			Column(
				modifier = Modifier.fillMaxSize(),
				verticalArrangement = Arrangement.SpaceBetween
			) {
				UserProfile(user)
				WrappedUserProfile(userUiState = userState)
				Button(viewModel::onButtonClick) {
					Text(text = "Set User")
				}
				OtherContent(otherState = otherState)
				Button(viewModel::onOtherAction) {
					Text(text = "Other Action")
				}
			}
		}
	}
}

@Composable
fun UserProfile(user: User) {
	println("User Profile Composable")
	Text(text = user.name)
}

@Composable
fun WrappedUserProfile(userUiState: UserUiState) {
	println("Wrapped User Profile Composable")
	Text(text = userUiState.user.name)
}

@Composable
fun OtherContent(otherState: Int) {
	println("Other Content Composable")
	Text(text = otherState.toString())
}

저희의 목표는 Compose에서 의도한대로 Skip or Recomposition이 발생하는 가를 보고싶기 때문에 전체적인 데이터를 보기 위해서는 app_debug-module.json, 각 Composable 함수의 decompile 결과를 보기 위해서는 app_debug-composables.txt, app_debug-composables.csv 를 보시면 됩니다.

해당 코드에 대한 metrics를 분석하기 위해 app_debug-composables.txt를 보겠습니다.

restartable scheme("[androidx.compose.ui.UiComposable]") fun UserProfile(
  unstable user: User
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun WrappedUserProfile(
  stable userUiState: UserUiState
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun OtherContent(
  stable otherState: Int
)

보시면 Presentation Layer에서 구성한 Wrapped User, OtherState를 인자로 넘겨주는 WrappedUserProfile, OtherContet는 정상적으로 skippable이 적용돼있는 반면, Domain Layer에서 구성한 User 모델을 직접 쓰는 USerProfile은 skippable이 정상적으로 붙어있지 않는 것을 볼 수 있습니다.

따라서, 이전 시리즈와 같이 @Stable 또는 @Immutable Annotation을 붙여줘야 제대로 동작함을 알 수 있습니다.

해당 프로젝트 코드는 아래에 있습니다.
https://github.com/TaehoonLeee/multi-module-compose-model-test

References


profile
https://www.linkedin.com/in/%ED%83%9C%ED%9B%88-%EC%9D%B4-7b9563237

1개의 댓글

comment-user-thumbnail
2023년 9월 4일

좋은 글 감사합니다~

답글 달기