Recomposition_최적화_하기 - boostcampwm-2024/and04-Nature-Album GitHub Wiki
Recomposition 최적화
- Home ✅
- Album ✅
- AlbumFolder ✅
- LabelSearch ✅
- MyPage ✅
- PhotoInfo ✅
- SavePhoto ✅
현재 우리 앱에 무분별하게 리컴포지션이 일어나는 것을 확인했다.
예를 들어, TextField의 값을 변경한다고 가정하면 해당 부분에서만 리컴포지션이 일어나야 하는데, 화면 전체가 리컴포지션이 일어나는 문제가 발생했다.
Recomposition 최적화 과정 전체를 문서화하기에는 너무 많았기에 핵심 부분만 문서화하기로 결정했다.
아래는 TextField를 수정했음에도 전체 화면이 리컴포지션되는 문제를 해결한 과정이다.
우선 수정 전의 코드는 아래와 같다.
private fun SearchContent(
context: Context,
innerPadding: PaddingValues,
onSelected: (Label) -> Unit,
query: State<String>,
onQueryChange: (String) -> Unit,
randomColor: State<String>,
containerColor: Color,
labelColor: Color,
labelsState: List<Label>,
) {
TextField(
modifier = modifier,
value = query.value,
onValueChange = onQueryChange,
placeholder = { Text(stringResource(R.string.label_search_label_search)) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent
)
)
}
위 상황일때는 TextField를 수정하면 수정된 컴포저블을 가지고 있는 SearchContent 컴포저블도 리컴포지션이 일어났다. SearchContent는 텍스트필드 뿐만 아니라 라벨 검색된 결과를 보여주는 LazyColum, 고정된 Text 등 리컴포지션이 일어나면 안되는 부분에서 리컴포지션이 일어났다.
하지만! 아래와 같이 작성하면 리컴포지션이 SearchContent는 리컴포지션이 일어나지 않는다.
private fun SearchContent(
context: Context,
innerPadding: PaddingValues,
onSelected: (Label) -> Unit,
query: State<String>,
onQueryChange: (String) -> Unit,
randomColor: State<String>,
containerColor: Color,
labelColor: Color,
labelsState: List<Label>,
) {
LabelTextField(Modifier.fillMaxWidth(), query, onQueryChange)
}
@Composable
private fun LabelTextField(
modifier: Modifier,
query: State<String>,
onQueryChange: (String) -> Unit,
) {
TextField(
modifier = modifier,
value = query.value,
onValueChange = onQueryChange,
placeholder = { Text(stringResource(R.string.label_search_label_search)) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent
)
)
}
SearchContent에서 query의 상태를 구독한 것이 아닌, 하위 컴포저블을 따로 만든 LabelTextField가 구독하도록 만들었기에 리컴포지션이 일어나지 않는 것이다.
이런 구조로 앱 최적화를 진행시켰다.
앱 실행 시, HomeScreen의 컴포지션 과정에서, ClippingButtonWithFile 컴포저블이 불필요하게 리컴포지션이 일어나는 문제를 확인했다.
이 버튼은 지도 버튼을 담당하는 부분이고, 동적으로 UI가 변하는 부분이 아닌, 정적인 UI 이기에 리컴포지션이 일어나는 이유를 파악하기로 했다.
우선 어떤 문제인지 코드를 확인해봤다.
var parsedPathData by remember { mutableStateOf("") }
var parsedViewportWidth by remember { mutableFloatStateOf(1f) }
var parsedViewportHeight by remember { mutableFloatStateOf(1f) }
LaunchedEffect(fileNameOrResId) {
val svgData = when {
isFromAssets && fileNameOrResId is String -> parseSvgFile(context, fileNameOrResId)
!isFromAssets && fileNameOrResId is Int -> parseDrawableSvgFile(
context,
fileNameOrResId
)
else -> null
}
if (svgData == null) return@LaunchedEffect
parsedPathData = svgData.pathData
parsedViewportWidth = svgData.viewportWidth
parsedViewportHeight = svgData.viewportHeight
}
현재 path와 ViewPort를 상태로 만들어 관리하고 있었다.
또한 이 상태를 초기화하는 부분을 LaunchedEffect를 통해 처리하여 값을 할당해주고 있는 구조이다.
우선 위 로직에서 초기 상태 (””, 1f, 1f)
에서 컴포지션이 일어났고, LaunchedEffect에서 비동기 적으로 값을 할당해주면서 리컴포지션이 일어났던 것이 문제 상황이었다.
하지만 지도 버튼은 단순히 정적인 버튼이고, 동적으로 UI가 변하는 부분이 전혀 아니기에 수정하기로 했다..
기존의 LaunchedEffect를 제거하였고, 아래와 같이 코드를 수정하면서 리컴포지션이 일어나는 것을 막을 수 있었다.
val svgData = when {
isFromAssets && fileNameOrResId is String -> parseSvgFile(context, fileNameOrResId)
!isFromAssets && fileNameOrResId is Int -> parseDrawableSvgFile(
context,
fileNameOrResId
)
else -> null
}
val parsedPathData = svgData?.pathData ?: ""
val parsedViewportWidth = svgData?.viewportWidth ?: 1f
val parsedViewportHeight = svgData?.viewportHeight ?: 1f
여기서 svgData
가 null 이 될 상황은 벡터 이미지 자체가 손상되는 상황인데, 이 손상된 이미지에 접근하여 여러번 path를 구하려는 시도를 한다고 정상적인 값을 가져올 수 있을까? 하는 의문이 들었다.
이전 화면에서 AlbumScreen으로 화면전환이 일어날 때 위 이미지처럼 리컴포지션이 2번에서 4번 일어나는 것을 확인했다. 화면 전환시에는 컴포지션만 일어나야하기에 리컴포지션이 일어날 이유가 없는 부분이다.
우선 어떤 부분에서 리컴포지션이 일어나는지 하나 하나 확인해야 했다.
여기서 추측할 수 있는 부분이 있었는데 그건 아래 코드에서 주석처리된 부분이다.
composable(NavigateDestination.Album.route) {
AlbumScreen(
onLabelClick = { labelId ->
selectedAlbumLabel = labelId //이 부분에서 리컴포지션 발생
navController.navigate(NavigateDestination.AlbumFolder.route)
},
onNavigateToMyPage = { navController.navigate(NavigateDestination.MyPage.route) },
)
}
문제가 되는 부분은 selectedAlbumLabel = labelId
이 부분인 것으로 예상하여 삭제 후 실행시켜봤다.
해결했다!!
해당 리컴포지션이 발생한 부분은 onClick 이벤트를 호출하면 실행되는 콜백임에도 단순히 클릭없이 첫 컴포지션 단계에서 2번의 리컴포지션이 일어났다는 것이다.
사실 아직은 잘 모르겠다… 그래도 원인은 찾았으니 하나 하나 디버깅하며 해결할 예정이다.
또한 이제 밑의 AlbumScreen
에서의 리컴포지션을 해결해야 한다.
두번의 리컴포지션이 일어났던 코드는 아래와 같다.
val isDataLoaded = rememberSaveable { mutableStateOf(false) }
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
val albumList = viewModel.albumList.collectAsStateWithLifecycle()
LaunchedEffect(onLabelClick) {
if (!isDataLoaded.value) {
viewModel.loadAlbums()
isDataLoaded.value = true
}
}
즉, 처음에는 isDataLoaded
가 false로 컴포지션이 일어나는데, 이후 viewmodel.loadAlbums()
를 호출하면서 리컴포지션이 일어나는 것이다.
이때 loadAlbums()
에서는 UiState.Idle의 상태를 Loading으로 바꾸고, albumList에 값을 넣어준 후 다시 UiState를 Sucess로 바꾸고 마지막으로 isDataLoaded를 true로 바꾸는 것이다.
리컴포지션이 일어나는 단계를 차근 차근 풀어보면
- 컴포지션 단계
- isDataLoaded : false
- uiState : UiState.Idle
- albumList : emptyList
- 리컴포지션 1
- isDataLoaded : false
- uiState : UiState.Loading
- albumList : emptyList
- 리컴포지션 2
- isDataLoaded : true
- uiState : UiState.Success
- albumList : List
의 과정을 가쳐 2번의 리컴포지션이 일어나는 것이다.
AlbumScreen에서 필요한 데이터를 애초에 viewmodel.init에서 호출하고 사용하면 불필요한 리컴포지션도 줄일 수 있고, viewmodel 인스턴스가 생성되면서 필요한 데이터를 바로 생성할 수 있는 장점이 있지 않을까? 하는 의문이 들었다.
그렇기에 AlbumScreen에서 호출하던 loadAlbums()
를 뷰모델 인스턴스가 생성되면 같이 호출되도록 수정하였다.
@HiltViewModel
class AlbumViewModel @Inject constructor(
private val repository: DataRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Idle)
val uiState: StateFlow<UiState> = _uiState
private val _albumList = MutableStateFlow<List<AlbumDto>>(emptyList())
val albumList: StateFlow<List<AlbumDto>> = _albumList
init {
loadAlbums()
}
fun loadAlbums() {
viewModelScope.launch {
_uiState.emit(UiState.Loading)
_albumList.emit(repository.getAllAlbum())
_uiState.emit(UiState.Success)
}
}
}
그렇게 하고나니 불필요한 리컴포지션이 일어나지 않고 한 번의 리컴포지션만 일어났다.
이 한번의 리컴포지션은 아래와 같이 uiState가 Loading 상태가 되었다가 Success 상태가 되면서 발생한 것이다.