시배's Android

Android | Compose에서 Custom View Cache 하기 본문

Android/Android

Android | Compose에서 Custom View Cache 하기

si8ae 2024. 1. 31. 13:51

프로젝트에서 Naver Map을 사용하여 지도 관련 Feature를 구현하여야 했습니다.

저희 프로젝트는 Compose로 작성되어 있었기 때문에 Compose용 Naver Map을 사용할 수 있는 라이브러리를 찾아 보았고, 안성용님께서 진행중이신 오픈소스를 찾을 수 있었습니다.

https://github.com/fornewid/naver-map-compose

 

GitHub - fornewid/naver-map-compose: NAVER Map Android SDK for Jetpack Compose 🗺

NAVER Map Android SDK for Jetpack Compose 🗺. Contribute to fornewid/naver-map-compose development by creating an account on GitHub.

github.com

 

마커를 구현하는 중에, 디자이너의 요구에 따라 일반적인 마커와는 다른 커스텀 마커를 만들어야 했습니다. 이를 위해 OverlayImage를 사용하여 마커 이미지를 커스텀화하는 작업이 필요하였습니다.

@AnyThread
@com.naver.maps.map.internal.b
public abstract class OverlayImage {
    @NativeApi
    @NonNull
    public final String id;

    @NonNull
    public static OverlayImage fromView(@NonNull View view) {
        return new c(com.naver.maps.map.internal.util.a.a(view));
    }

    @NonNull
    public static OverlayImage fromBitmap(@NonNull Bitmap bitmap) {
        return new c(bitmap);
    }

    @NonNull
    public static OverlayImage fromResource(@DrawableRes int resourceId) {
        return new e(resourceId);
    }

    @NonNull
    public static OverlayImage fromAsset(@NonNull String assetName) {
        return new b(assetName);
    }

    @NonNull
    public static OverlayImage fromPrivateFile(@NonNull String fileName) {
        return new d(fileName);
    }

    @NonNull
    public static OverlayImage fromPath(@NonNull String absolutePath) {
        return new a(new File(absolutePath));
    }

    @NonNull
    public static OverlayImage fromFile(@NonNull File file) {
        return new a(file);
    }

    private OverlayImage(@NonNull String id) {
        this.id = id;
    }
 //...

OverlayImage는 Bitmap, View, DrawableResource 등을 네이버맵용 icon 이미지로 변환을 해주는 함수가 있었습니다. 하지만 Composable function을 통해서는 변환을 할 수 없었기 때문에 XML을 이용한 Custom View 작성이 필요로 하였습니다.

CustomView 클래스는 즐겨찾기 여부에 따라 달라지는 ImageView와 텍스트를 포함하고, 배경으로는 말풍선 이미지를 사용하는 구조입니다.

class CustomView(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {

    private val binding: ItemCustomBinding =
        ItemCustomBinding.inflate(
            LayoutInflater.from(context),
            this,
            true
        )

    fun setData(markerData: MarkerData) {
        if (markerData.isFavorite) {
            binding.favoriteIcon.visibility = View.VISIBLE
        } else {
            binding.favoriteIcon.visibility = View.GONE
            binding.container.setPadding(
                12.dpToPx(context),
                7.dpToPx(context),
                12.dpToPx(context),
                10.dpToPx(context)
            )
        }

        binding.text.text = markerData.text
    }

}

위와 같이 ConstraintLayout을 상속받는 CustomView 클래스를 간단하게 작성하였습니다.

val marker = CustomView(context).also{ it.setData(true, "2/2") }
Marker(
    icon = OverlayImage.fromView(marker.rootView),
    state = MarkerState(
    position = LatLng(
        data.lat,
        data.lng
        )
    ),
)

작성한 CustomView의 rootView를 통해 OverlayImage.fromView를 통해 Marker의 icon 이미지를 설정할 수 있습니다.

그러나 지도 상에는 수많은 중복 마커가 존재하고, 이를 모두 CustomView 객체를 생성하여 표시하면 리소스 소모가 많아집니다. 이를 개선하기 위해 CustomView를 캐싱하는 작업이 필요합니다.

class CustomViewCache {

    val markerViewMap = mutableMapOf<MarkerData, CustomView>()

    fun getOrCreateView(
        context: Context,
        markerData: MarkerData
    ): CustomView =
        markerViewMap[markerData]
            ?: createNewView(context, markerData)
                .also {
                    Log.e("ABC", "Cache Miss")
                    markerViewMap[markerData] = it
                }

    private fun createNewView(
        context: Context,
         markerData: MarkerData
    ): CustomView =
        CustomView(context).also {
            it.setData(markerData)
        }

}

이를 위해 CustomViewCache 클래스를 작성하여 Map<MarkerData, CustomView> 형식의 맵을 생성했습니다. 이 맵은 같은 데이터를 가진 마커에 대해 같은 CustomView를 반환하고, 새로운 데이터의 경우에는 새로운 CustomView를 생성하고 맵에 저장합니다.

val markerCache = ChargingStationMarkerViewCache()

val marker = markerCache
    .getOrCreateView(
        context,
        MarkerData(
            isFavorite,
            text
        )
    )

Marker(
    icon = OverlayImage.fromView(marker.rootView),
    state = MarkerState(
        position = LatLng(
            data.lat,
            data.lng
        )
    ),
)

그러나 이방법에도 문제가 있었습니다.

recomposition이 발생할 때마다 CustomViewCache 객체를 새로 생성하게 되어 Cache Miss가 Log에 출력되고 있었습니다. 이를 해결하기 위해서는 recomposition 상황에서도 state를 유지할 수 있는 remember의 사용이 필요로 하였습니다.

@Composable
fun rememberCustomViewCache(): CustomViewCache {
    return rememberSaveable(saver = CCustomViewCacheSaver()) {
        CustomViewCache()
    }
}

class CustomViewCacheSaver :
    Saver<CustomViewCache, Map<MarkerData, CustomView>> {
    override fun restore(value: Map<MarkerData, CustomView>): CustomView {
        val cache = CustomViewCache()
        cache.markerViewMap.putAll(value)
        return cache
    }

    override fun SaverScope.save(value: CustomViewCache): Map<MarkerData, CustomView> {
        return value.markerViewMap.toMap()
    }
}

rememberSaveable을 활용하여 상태를 보존하도록 CusomSaver를 작성하여 구현하였습니다.



recomposition 상황이 발생하였지만 Cache Miss 로그가 발생하지 않는것을 확인할 수 있었습니다.

그러나 이 방법도 문제가 있는데 rememberSaveable은 Bundle을 이용하여 Configuration Change 상황에서도 state를 보존하므로, context를 참조하고있는 CustomView에 의해 Memory Leak이 발생할 수 있습니다.

val customViewCache by remember {
    mutableStateOf(CustomViewCache())
}

그러기 때문에 Configuration Change에도 상태는 보존할 수 없지만 recomposition에 대응할 수 있는 remember만을 이용하여 구현하였습니다.

@Stable
class CustomViewCache {

    private val markerViewMap = mutableMapOf<MarkerData, CustomView>()

CustomViewCache에서는 Map을 사용하여 CustomView를 캐싱하고 있습니다. 이를 더 효율적으로 개선하기 위해 WeakReference를 활용하여 메모리 관리를 향상시킬 수 있습니다.

@Stable
class CustomViewCache {

    private val markerViewMap = WeakHashMap<MarkerData, WeakReference<CustomView>>()

    fun getOrCreateView(
        context: Context,
        markerData: MarkerData
    ): ChargingStationMarkerView =
        markerViewMap[markerData]?.get()
            ?: createNewView(context, markerData)
                .also {
                    Log.e("ABC", "Cache Miss")
                    markerViewMap[markerData] = WeakReference(it)
                }

    private fun createNewView(
        context: Context,
        markerData: MarkerData
    ): CustomView =
        CustomView(context).also {
            it.setData(markerData)
        }

}

WeakHashMap을 통해 markerViewMap을 만들었습니다. WeakHashMap은 key에 대한 값은 weak refrence로 관리하지만 value에 대한 값은 strong reference로 관리하기 때문에 value값 역기 WeakRefrence로 활용하여 메모리 관리를 향상시켰습니다.