본문으로 바로가기
반응형

Photo from unsplash

다운로드 받은 모듈은 앞선 SplitCompat 관련된 코드로 접근이 가능합니다. Base모듈의 Application 단에서 SplitCompat.install()을 수행하고 Dynamic feature module의 Activity에서SplitCompat.installActivity()를 호출하여 두 모듈을 연결해 주는 작업을 합니다. 이번에는 각 모듈 간 resource에 접근하고 호출하는 부분에 대한 내용을 설명합니다.

SplitCompat의 설정

이전글에서도 언급했지만 다운로드가 완료된 후 모듈에 access 하려면 Base Module과 Dynamic Moudule에 아래와 같이 선언되어야 합니다.

Base Module

- Application class에 SplitCompat.install() 설정 (또는 다른 두 가지 방법[1]으로도 설정 가능)

class MyLittleWorldApplication : Application() {
    ...

    override fun attachBaseContext(base: Context) {
        super.attachBaseContext(base)
        SplitCompat.install(this)
    }
}

- Dynamic module activity를 위한 BaseSplitActivity 추가

abstract class BaseSplitActivity : AppCompatActivity() {
    override fun attachBaseContext(context: Context) {
        super.attachBaseContext(context)
        SplitCompat.installActivity(this)
    }
}

DynamicModule

- Activity는 Base moudle에서 추가해 놓은 BaseSplitActivity를 상속받아 구현

class Feature1MainActivity : BaseSplitActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
    }
}

추가적인 설명을 위해 Developer에 있는 내용을 발췌하면 아래와 같습니다.

Access Asset of Dynamic Module

Dynamic Module의 Asset에 다음과 같이 "testAsset.txt" 파일이 존재 합니다. 

Base module에서 다운로드 받은 Module에 Access 하기 위해서는 아래와 같이 다운로드가 완료된 상태(Installed) 이후 (INSTALLED callback이 오면) 접근합니다.

private fun processInstallState(state: SplitInstallSessionState) {
    when (state.status()) {
        SplitInstallSessionStatus.DOWNLOADING -> {
            ...
        }
        SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
            ...
        }
        SplitInstallSessionStatus.INSTALLED -> {           
              getAssetWithInit()
              getAssetByAppContext()
              getAssetsByActivityContext()
        }

        SplitInstallSessionStatus.INSTALLING -> {
           ...
        }
        SplitInstallSessionStatus.FAILED -> {
            ...
        }
    }
}

Asset에 접근하기 위한 세개 함수의 코드는 동일하며, 각각 AssetManager를 얻어오는 Context 부분만 다릅니다.

private fun getAssetWithInit() {

    // Get the asset manager with a refreshed context, to access content of newly installed apk.
    val assetManager = createPackageContext(packageName, 0).also {
          SplitCompat.install(it)
    }.assets
    // Now treat it like any other asset file.
    val assetsStream = assetManager.open("testAsset.txt")
    val assetContent = assetsStream.bufferedReader()
       .use {
            it.readText()
        }
    LogDebug(TAG) { "getAssetWithInit() - $assetContent" }
}

private fun getAssetByAppContext() {
    val assetManager = application.assets
    ...
}

// 이미 화면에서 보여지고 있던 Base module의 Acitivty 내부에서 호출
private fun getAssetsByActivityContext() {
    val assetManager = assets
    ...
}
  • getAssetWithInit()
    • 다운로드가 완료되면 다운로드된 모듈의 resource에 접근할 수 있습니다.
    • createPackageContext()로 새로운 context를 생성하고 SplitCompat.install()로 해당 Context를 등록(install) 시킨 후 AssetManager를 가져옵니다.
    • 정상적으로 "testAsset.txt"의 내용을 가져올 수 있습니다.
    • 만약 SplitCompat.install()을 호출하지 않으면 FileNotFoundException이 발생합니다.
  • getAssetByAppContext()
    • AssetManager를 application context를 통해 가져옵니다.
    • 이 코드에서는 정상적으로 "testAsset.txt"의 내용을 가져올 수 있습니다.
    • 다만 이는 앞쪽에서 getAssetWithInit()를 호출하면서 SplitCompat.install()이 호출되었기 떄문입니다.
    • 이 함수만 단독으로 호출되면 FileNotFoundException이 발생합니다.
  • getAssetsByActivityContext()
    • Activity의 context를 이용하여 AssetManager를 가져옵니다. Activity의 context는 Dynamic module install 이전에 이미 존재하던 context이므로 다운로드된 모듈의 리소스 정보가 context에 존재하지 않습니다.
    • 따라서 FileNotFoundException이 발생합니다.

보통 Application과 Acitvity에 SplitCompat을 설정해 놓으면 모듈이 다운로드된 이후에 해당 모듈이 APK 일부인 것처럼 모듈의 코드 및 리소스를 사용할 수 있습니다. 해당 예제에서는 Activity를 띄우지 않았기 때문에 다른 모듈에서 다운로드 받은 Asset에 접근하기 위해서는 Application context를 이용해야 합니다.

만약 아래와 같이 Dynamic module에 존재하는 Activity를 수행시킨 후에 AcitvityContext로 AssetManager에 접근하면 어떻게 될까요?

private fun processInstallState(state: SplitInstallSessionState) {
    when (state.status()) {
        SplitInstallSessionStatus.DOWNLOADING -> {
            ...
        }
        SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
            ...
        }
        SplitInstallSessionStatus.INSTALLED -> {
            startNewFeatureModule()//다운로드 받은 모듈의 activity를 수행 
            launch {
                delay(500)
                getAssetsByActivityContext() //현재 Activity context로 Asset 접근
            }
        }

        SplitInstallSessionStatus.INSTALLING -> {
           ...
        }
        SplitInstallSessionStatus.FAILED -> {
            ...
        }
    }
}

// 다운로드 받은 모듈안에 존재하는 Activity를 start 한다
private fun startNewFeatureModule() {
    val intent = Intent().setClassName(BuildConfig.APPLICATION_ID, ACTIVITY_CLASS_NAME)
    startActivity(intent)
}

먼저 모듈의 다운로드가 끝나면 모듈 내부에 존재하는 Activity를 start 시킵니다. 다운로드 모듈의 모든 Activity는 Base module에서 정의한 BaseSplitActivity를 상속받고 있기 때문에 Activity가 시작되면 SplitCompat.installActivity()가 호출되도록 되어 있습니다.

Dynamic module의 Activity를 start시킨 후에 Base module의 Activity에서 Activity context를 이용하여 Dynamic 모듈의 Asset에 접근하면 이때는 위의 케이스와는 다르게 정상적으로 접근이 가능합니다.

Access Drawable of Dynamic module

Feature Module의 resource에 아래와 같이 Drawable이 존재하도록 위치시켰습니다.

이번에도 Base Module에서 Download 된 모듈에 접근해서 drawable을 얻어오는 방법을 알아보도록 합니다.

먼저 다운로드된 모듈의 drawable을 접근하기 위해서 Android Developer에서는 아래와 같은 방법을 제시하고 있습니다. [5]

val uri = Uri.Builder()
      .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
      .authority(context.getPackageName()) // Look up the resources in the application with its splits loaded
      .appendPath(resources.getResourceTypeName(resId))
      .appendPath(String.format("%s:%s",
        resources.getResourcePackageName(resId), // Look up the dynamic resource in the split namespace.
        resources.getResourceEntryName(resId)
        ))
      .build()

해당 방법으로 요청 시 uri는 아래와 같이 표현됩니다. 

  • App Package Name: com.example.my_app_package
  • Feature Module Package Name: com.example.my_app_package.my_dynamic_feature
  • raw_file: my_video

Uri 값

android.resource://com.example.my_app_package/raw/com.example.my_app_package.my_dynamic_feature:my_video

분리된 모듈이기 때문에 R.drawable.xxx로는 표기하게 되면 compile 오류가 발생하므로 위에서 언급된 Uri로 접근하는 방법 이외에 string 값으로 resource에 접근하는 여러 가지 방법을 사용하여 접근을 시도하는 코드는 아래와 같습니다.

private fun processInstallState(state: SplitInstallSessionState) {
    when (state.status()) {
        SplitInstallSessionStatus.DOWNLOADING -> {
            ...
        }
        SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
            ...
        }
        SplitInstallSessionStatus.INSTALLED -> {           
             getNewModuleResImageByInit()
             getNewModuleResImageByAppContext()
             getNewModuleResImageByUri()
        }

        SplitInstallSessionStatus.INSTALLING -> {
           ...
        }
        SplitInstallSessionStatus.FAILED -> {
            ...
        }
    }
}

먼저 install이 완료되면 세 개의 함수를 호출하여 Feature Module에 존재하는 Drawable resource에 접근하도록 합니다

getNewModuleResImageByInit()

Asset에 접근할 때와 같이 createPackageContext()를 이용하고 SplitCompat.install()을 호출하는 방식으로 구현합니다.

R.drawable.xxx로 resource에 접근할 수 없으므로 resource.getIdentifier()를 사용합니다.

 private fun getNewModuleResImageByInit() {
    LogDebug(TAG) { "getNewModuleResImageByInit()" }
    val newContext = createPackageContext(packageName, 0).also {
        SplitCompat.install(it)
    }

    val resStringId = "sample2"
    val resId =
        newContext.resources.getIdentifier(resStringId,
        "drawable", BuildConfig.APPLICATION_ID)


    if (resId != 0) {
        viewBinding.resourceFromFeature2.setImageResource(resId)
    } else {
        LogError(TAG) { "getNewModuleResImageByInit() - Failed get ResId" }
    }
}

getNewModuleResImageByAppContext()

기본적인 코드는 동일하나 Applictaion context를 이용하여 drawable에 접근합니다.

private fun getNewModuleResImageByAppContext() {
    LogDebug(TAG) { "getNewModuleResImageByAppContext()" }

    val resStringId = "sample3"
    val resId = applicationContext.resources.getIdentifier(
        resStringId,
        "drawable",
        BuildConfig.APPLICATION_ID
    )

    if (resId != 0) {
        viewBinding.resourceFromFeature3.setImageResource(resId)
    } else {
        LogError(TAG) { "getNewModuleResImageByAppContext() - Failed get ResId" }
    }
}

getNewModuleResImageByUri()

위에서 언급한 대로 Uri를 이용하여 Dynamic feature moudle의 resource에 접근합니다.

private fun getNewModuleResImageByUri() {
    LogDebug(TAG) { "getNewModuleResImageByUri()" }
    val resName = "sample3"
    val uri = Uri.Builder()
        .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
        .authority(this.getPackageName()) // Look up the resources in the application with its splits loaded
        .appendPath("drawable")
        .appendPath(String.format("%s:%s",FEATURE_PACKAGE, resName))          
        .build()

    LogDebug(TAG) { "getNewModuleResImageByUri() - $uri" }
    viewBinding.resourceFromFeature4.setImageURI(uri)
}

안타깝게도 세 개의 방법 모두 resource를 정상적으로 가져올 수 없습니다. 가장 쉬운 방법은 Base module에서 접근이 필요해 보이는 resource에 대해서는 base module에 미리 넣는 방법이나, App bundle에서 Dynamic Feature module의 on-demand 요청에 의한 용량 확보면에서는 좋아 보이지 않습니다.

 

Base Module에서 Dynamic Module의 Function 호출

위의 경우 Dynamic function에서 해당 resource를 반환하는 함수를 생성하여 Base module에서는 이 함수를 통해서 resource를 가져오도록 하면 될 것 같아 보입니다. 이 작업을 수행하기에 앞서 먼저 고려해야 할 상황은 두 모듈 간의 관계입니다.

https://medium.com/androiddevelopers/patterns-for-accessing-code-from-dynamic-feature-modules-7e5dca6f9123

이전에 설명했었던 Dynamic Module의 build.gradle의 내용 중에 아래와 같은 내용이 존재합니다.

dependencies {
    implementation project(":app")
    ...
}

즉 Dynamic module은 base가 되는 module을 포함합니다. 따라서 Dynamic module에서 base module의 resource 접근뿐만 아니라 함수까지 하나의 app에 있는 것처럼 호출할 수 있습니다.

문제는 반대의 경우로 Base moudle -> Dynamic moudle의 호출입니다. Base Module 입장에선 Dynamic module이 언제 install 될지 모르기 때문에 Complie time에서는 Dynamic module의 resource에 접근하거나, 함수를 호출할 수 없습니다.  Base module에서 Dynamic module의 drawable에 접근할 때 R.drawable.xxx를 사용하지 못하는 것도 이런 이유 때문입니다.

Reflection의 사용

따라서 Base -> Dynamic module의 호출을 위해서는 reflection을 사용해야 합니다. 하지만 매번 함수를 호출할 때마다 사용할 수는 없으니, 딱 1번만 reflection을 사용하는 형태로 구현합니다. [3] 또한 이는 Google에서 제공하는 Sample을 기반으로 설명하므로 Google Sample을 git에서 다운로드 받아서 코드 확인이 가능합니다. [4]

1. Base Module -> Feature Module에 요청할 함수의 Interface 구현 (Base Module에 추가)

// Base Module에 추가
interface IFeature1Accessor {
    fun getSample1Drawable(): Drawable
    fun showFeatureToast()
}

첫번째로 Base Module -> Dynamic Module에 호출하고 싶은 함수를 정의한 Interface를 만듭니다. 예제의 경우 위에서 실패했던 drawable resource를 얻는 getSample1Drawable() 함수와 Dynamic Feature 모듈에서 toast를 띄워주기 위한 함수를 추가했습니다. 해당 코드의 실체 구현은 당연히 Dynamic Feature에서 하게 됩니다.

2. IFeature1Accessor를 제공하는 Module Provider Interface 추가 (Base Module에 추가)

원하는 함수가 포함된 Interface가 정의되었으니 이제 이 interface의 instance를 제공해주는 Module Provider를 하나 만듭니다. 이 Module Provider는 Feature1Accessor의 객체를 제공합니다.

// Base Module에 추가
interface IModuleProvider {
    fun get(): IFeature1Accessor
}

 

3. Accessor / Provider interface를 구현 (Dynamic Module에 추가)

//Dynamic moudle에 추가
class Feature1AccessorImpl: IFeature1Accessor  {
    override fun getSample1Drawable(): Drawable {
        return DynamicModuleTestApplication.getInstance().applicationContext
        .resources.getDrawable(R.drawable.sample1, null)
    }

    override fun showFeatureToast() {
        Toast.makeText(
        DynamicModuleTestApplication.getInstance().applicationContext,
        "Feature1 Module toast", Toast.LENGTH_SHORT
        ).show()
    }

    companion object Provider: IModuleProvider {
        override fun get(): IFeature1Accessor {
            return DaggerFeatureComponent.create().getAccessor()
        }
    }
}

Accessor Interface를 상속받아 필요한 함수를 override 합니다.

추가적으로 이 class의 instance를 반환해주는 IModuleProvider를 상속받은  companion object를 정의합니다. get() 함수를 호출하면 Accessor의 instance를 반환해 주며 이때 instance의 생성은 Dagger를 사용했습니다. 따라서 Dagger를 위한 class들을 추가합니다.

4. Accessor 생성을 위한 Dagger 구성 (Dynamic Module에 추가)

// Dynamic Module에 추가
@Module
class FeatureModule {
    @Provides
    internal fun provideFeature1AccessorImpl(): IFeature1Accessor
                                                = Feature1AccessorImpl()
}
// Dynamic Module에 추가
@Component(modules = [FeatureModule::class])
interface FeatureComponent {
    fun getAccessor(): IFeature1Accessor
}

간략하게 Module과 Component를 추가했습니다. 따라서 이제 FeatureComponent를 이용하여 Feature1AccessorImpl 객체를 생성할 수 있습니다.

이제 Dynamic Module에서의 구현은 끝났습니다. 다시 Base Module로 돌아가 남은 작업을 추가합니다.

5. Accessor 생성을 위한 Reflection을 Dagger에 구현 (Base Module에 추가)

Base Module에서 Accessor를 생성하기 위해서는 Dagger를 사용하도록 합니다. 또한 Dagger에서 실제 Accessor의 생성 부분에서는 reflection을 이용합니다. 아래와 같이 Dagger의 Module을 추가합니다.

// Base Module에 추가
@Module
object BaseModule {
    private const val TAG = "BaseModule"
    private const val PROVIDER_CLASS = BuildConfig.APPLICATION_ID 
                          + ".ondemand.feature1.Feature1AccessorImpl\$Provider"
    private var feature1Accessor: IFeature1Accessor? = null

    @Provides
    fun provideFeature1Accessor(): IFeature1Accessor? {
        if (feature1Accessor != null) {
            return feature1Accessor
        }

        return try {
            val provider = Class.forName(PROVIDER_CLASS)
                           .kotlin.objectInstance as IModuleProvider
            
            provider.get().also { 
                //cache the value for later calls
                feature1Accessor = it 
            }
        } catch (e: ClassNotFoundException) {
            Log.e(TAG, "Provider class not found", e)
            null
        }
    }
}

Dagger Module의 ProvideFeature1Accessor()에서 아래 순서에 따라 Accessor를 받아 옵니다.

  • reflection을 이용하여 Module Provider를 찾음.
  • Module Provider가 찾아지면 get() 함수를 호출하여 Accessor 반환 받음
  • 반환된 Accessor는 변수에 저장하여 추후 재활용
  • 찾지 못할 경우 ClassNotFoundException이 발생하며, null을 반환함.

만약 Dynamic Module이 install 된 상태라면 Provider를 통해 Accessor를 반환하며, install이 되지 않았다면 null을 반환합니다. 이제 module을 호출할 수 있도록 Dagger component도 추가합니다.

// Base Module에 추가
@Component(modules = [BaseModule::class])
interface BaseComponent{
    fun getFeature1Accessor(): IFeature1Accessor?
}

6. Base Module에서 Accessor 호출 (Base Module에 추가)

Dagger까지 준비가 완료되었으므로 Base Module에서 Dynamic Module의 Sample Drawable을 받아오는 함수를 호출해 보도록 합니다.

private fun getNewModuleResImageByDagger() {
    LogDebug(TAG) { "getNewModuleResImageByDagger()" }
    val featureAccessor = DaggerBaseComponent.create().getFeature1Accessor()
    if (featureAccessor != null) {
        viewBinding.resourceFromFeature1.setImageDrawable(
               featureAccessor.getSample1Drawable()
        )
    } else {
        LogError(TAG) { "getNewModuleResImageByDagger() - Failed get accessor" }
    }
}

 

Summary

다운로드 받은 모듈을 즉시 사용하기 위해 SplitCompat의 사용 방법과 Resource의 접근방법에 대해서 정리했습니다. 마지막으로 Base module에서 Reflection을 이용하여 Dynamic Module에 접근하는 방법까지 설명이 완료되었습니다.

모듈을 분리함에 따라 얻는 장점도 존재하지만 모듈 간 상호 호출을 위해서 부가적인 코드들이 삽입되어야 하며, function call을 위해서는 reflection도 필수적으로 사용되어야 합니다. Android Framework에서 숨겨놓은 Hidden API를 사용하기 위해 불안요소를 가지고 reflection을 쓰는 정도까지는 아니더라도 reflection을 써야 한다는 것 자체가 부담스러운 일입니다. 그나마 다행인 건 모듈 자체도 하나의 App을 동작시키기 위한 구성 요소이므로 변경이 되더라도 서로 약속된 형태로 변경이라 Human error만 없다면 큰 문제가 되지는 않습니다.

하지만 모듈 분리로 인하여 덩치 큰 앱의 용량이 줄어든다는 궁극적인 장점은 버릴 수 없을 것 같네요.

다음 포스팅에서는 마지막으로 Dynamic moudle을 테스트하기 위한 간단한 Play console 설정에 대해서 정리합니다. 그리고 보니 모듈 분리의 테스트를 위해서는 Play console 계정이 반드시 필요하며, 일단 마켓에 런칭된(Play console에 등록된) 앱도 필요하다는 점 또한 단점입니다.

References

[1] 2021/01/18 - [개발이야기/Android] - [App bundle] Dynamic Module Download #4

[2] developer.android.com/reference/com/google/android/play/core/splitcompat/SplitCompat

[3] medium.com/androiddevelopers/patterns-for-accessing-code-from-dynamic-feature-modules-7e5dca6f9123

[4] github.com/googlesamples/android-dynamic-code-loading

[5] developer.android.com/guide/app-bundle/play-feature-delivery

 

 

 

 

반응형