본문으로 바로가기

[App bundle] Dynamic Module Download #4

category 개발이야기/Android 2021. 1. 18. 23:18
반응형

Photo from unsplash

앞선 주제들에서 Dynamic module을 모듈에 추가했습니다.

install-time에 다운로드를 하는 경우 크기 신경 쓸 것이 없지만 조건부 다운로드나, 사용자 요청에 의한 다운로드 시 모듈을 Google play에서 다운로드 받는 방법에 대해서 알아봅니다. 이미 구글에서 제공하는 Sample 코드가 존재하므로 이를 바탕으로 직접 구현한 예제를 가지고 코드를 살펴봅니다. [1]

 

환경설정

Module의 다운로드 및 Module 간 통신을 위해서는 Play Core library를 사용해야 합니다. 이는 앱과 Google Play를 연결하는 runtime interface입니다. 먼저 library를 사용하기 위해서 gradle에 아래와 같이 추가합니다.

dependencies {    
    implementation 'com.google.android.play:core:1.9.0'    
    implementation 'com.google.android.play:core-ktx:1.9.0'
    ...
}

core-ktx는 kotlin을 이용하여 extension function을 제공하고 API를 coroutine에서 사용하기 위한 suspend function을 추가로 제공합니다. 실제 예제에서 coroutine을 이용하여 API 호출하는 부분을 포함할 예정이므로 미리 추가해 두겠습니다. [2]

테스트 환경 설정

Base app에 "newFeature1"이란 on-demand 모듈을 추가한 상태에서 시작합니다. 모듈 추가 방법은 이전 글에서 이미 상세히 설명하였으므로 추가 설명을 하지는 않습니다.

Base Module에 아래와 같은 시작 Activity를 하나 만듭니다.

단순히 버튼만 추가하고 각 아이디는 아래와 같이 만듭니다. 이번에는 모듈의 다운로드와 삭제 버튼만 처리하고 나머지는 다음 포스팅에서 다룹니다.

<Button
        android:id="@+id/start_new_feature_btn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/text_btn1"
       .../>

    <Button
        android:id="@+id/remove_new_feature"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/text_btn2"
        .../>

    <Button
        android:id="@+id/load_feature_resource"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/text_btn3"
        .../>

    <Button
        android:id="@+id/load_feature_function"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/text_btn4"
        .../>

 

SplitCompat 설정

on-demand module의 경우 app install 당시에는 존재하지 않습니다. 따라서 Play core library를 통해 다운로드하며, Dynamic module의 다운로드가 끝나면 앱 재시작 없이 module의 코드나 리소스에 접근하기 위해서 SplitCompat을 설정해야 합니다. [3]

1.  Application에 설정

Application에 설정하는 방법은 세 가지입니다.

- App의 AndroidManifest.xml에 명시

<application
    ...
    android:name="com.google.android.play.core.splitcompat.SplitCompatApplication">
</application>

다만 이는 이미 Application을 상속받은 class가 없을 경우에만 가능합니다.

- SplitCompatApplication 상속

이미 Application을 상속받고 있다면 SplitCompatApplication을 상속받도록 변경합니다.

class MyApplication : SplitCompatApplication() {
    ...
}

- attachBaseContext() override

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

SpliCompatApplication 역시 attachBaseContext를 단순 override 하고 있습니다. 따라서 기존에 Application을 상속받아 사용하고 있는 class가 있다면 override 하는 방법으로 추가가 가능합니다.

2. Dynamic Module의 Activity

on-demand로 다운로드된 Module안에 존재하는 activity에서도 SplitCompat.installActivity()를 호출해야 합니다. 따라서 Base module에 아래와 같이 BaseSplitActivity를 생성하고 그 외 모듈 Activity에서 상속받아서 사용하도록 합니다

// Base module에 정의
abstract class BaseSplitActivity : AppCompatActivity() {

    override fun attachBaseContext(context: Context) {
        super.attachBaseContext(context)
        SplitCompat.installActivity(this)
    }
}
// newFeature1 module의 activity에서 BaseSplitActivity를 상속받아 사용

class Feature1MainActivity : BaseSplitActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_feature1_main)

        supportActionBar?.hide()
    }
}

이렇게 양쪽에 설정을 통해 base application context와 feature module의 activity 들에 SplitCompat을 enable 시켜 놓으면, 원래 한 개의 base APK 였던 것처럼 사용할 수 있습니다. 또한 설치된 모듈의 asset이나 resource에 접근하기 위해서는 해당 component context가 아닌 application context를 이용해야 합니다. 아니면 install 직후에 해당 component를 recreate 하거나 SplitCompat을 install 해서 사용해야 합니다.

On-demand moudle download

만들어 놓은 네 개의 버튼 중에 맨 처음 버튼인 "FEATURE TEST" 버튼을 클릭하면 모듈을 다운로드하고 다운로드한 모듈의 Activity로 이동하도록 하는 기능을 만들어 보겠습니다.

사용자 요청에 의해 모듈을 다운로드하기 위해서는 먼저 SplitInstallManager를 생성합니다. [4]

 private lateinit var splitInstallManager: SplitInstallManager

 override fun onCreate(savedInstanceState: Bundle?) {
    ...
    splitInstallManager = SplitInstallManagerFactory.create(this)
    ...
 }

SplitInstallRequest builder를 통해서 addModule을 이용하여 다운로드할 모듈을 요청합니다. 이때 요청되는 이름은 모듈 생성 시에 만들었던 module name을 사용합니다.

companion object {
    private const val MODULE_NAME_NEW_FEATURE1 = "newfeature1"
    private const val MODULE_NAME_NEW_FEATURE2 = "newfeature2"
}

private var sessionId: Int = -1

private fun downloadModule() {
    val request = SplitInstallRequest.newBuilder()
            .addModule(MODULE_NAME_NEW_FEATURE1)
            //.addModule(MODULE_NAME_NEW_FEATURE2)
            .build()

    splitInstallManager.startInstall(request)
        .addOnSuccessListener { sId ->
            sessionId = sId
            LogDebug(TAG) { "startNewFeatureModule() download success - id:$sId" }
        }
        .addOnFailureListener { e ->
            LogError(TAG, e) { "startNewFeatureModule() - download error" }
            downloadFailProcess((e as SplitInstallException).errorCode)
        }.addOnCompleteListener {
            LogDebug(TAG) { "startNewFeatureModule() request complete" }
        }
}

SplitInstallRequest.startInstall()를 이용하여 Google Play에 다운로드를 요청합니다. 이때 앱은 foreground에 있어야 합니다.

성공/실패/완료 관련된 callback을 받을 수 있으며, OnSuccessListener에서 sessionId를 받아 저장해 놓습니다. 만약 다운로드가 실패한다면 아래와 같은 값을 반환받을 수 있습니다.

 private fun downloadFailProcess(errCode: Int) {
    when (errCode) {
        SplitInstallErrorCode.NETWORK_ERROR -> toast(this, "네트워크 에러")
        SplitInstallErrorCode.MODULE_UNAVAILABLE -> toast(
                this, "GooglePlay를 통해 모듈 다운로드가 불가능 합니다.")
        SplitInstallErrorCode.INVALID_REQUEST -> toast(
                this, "GooglePlay에서 요청을 수신했지만 valid 하지 않습니다.")
        SplitInstallErrorCode.SESSION_NOT_FOUND -> toast(
                this, "Session 모니터링을 위한 session ID가 잘못되었습니다.")
        SplitInstallErrorCode.API_NOT_AVAILABLE -> toast(
                this, "GooglePlay를 지원하지 않는 기기입니다.")
        SplitInstallErrorCode.ACCESS_DENIED -> toast(
                this, "권한문제로 요청을 처리 목했습니다. 앱이 포그라운드 상태에서 시도하세요.")
        SplitInstallErrorCode.INCOMPATIBLE_WITH_EXISTING_SESSION -> toast(
                this, "이미 요청된 상태입니다.")
        SplitInstallErrorCode.ACTIVE_SESSIONS_LIMIT_EXCEEDED -> {
            toast(this, "이미 하나 이상의 모듈 다운로드중")
            checkForActiveDownloads()
        }
    }
}

startInstall을 수행하면 정상적인 경우 Success 로그와 함께 Complete 로그가 출력됩니다. 이는 Google play에 install 요청이 정상적으로 수행되었다는 의미이며, 다운로드가 완료된 상태는 아닙니다.

마지막 항목인 ACTIVE_SESSIONS_LIMIT_EXCEEDED는 이미 다운로드 중인 모듈을 재 요청하는 경우에 발생합니다. 따라서 이런 경우 startInstall로 요청한 모듈 중 다운로드 중인 모듈은 제외한 후 재 요청하거나 deferred install을 사용하도록 합니다.

private fun checkForActiveDownloads() {
    val task: Task<List<SplitInstallSessionState>> = splitInstallManager.sessionStates
    task.addOnCompleteListener { taskList ->
        if (taskList.isSuccessful) {
            for (state in task.result) {
                if (state.status() == SplitInstallSessionStatus.DOWNLOADING) {
                    // Cancel the request, or request a deferred installation.
                    LogDebug(TAG) { 
                    "checkForActiveDownloads() downloading ${state.sessionId()}"
                    }
                }
            }
        }
    }
}

만약 바로 설치할 필요가 없으면 앱이 background에서 설치하도록 하려면 SplitInstallManager.deferredInstall()을 사용하면 됩니다. 

startInstall을 수행한 경우 Google Play에 요청 후 해당 코드는 완료됩니다. 따라서 실제로 다운로드가 일어나면 다운로드할 앱의 크기가 큰 경우 다운로드 상태를 사용자에게 노출해 줄 수 있도록 다운로드 전체 크기와 진행 사항을 제공해 줄 수 있습니다.

먼저 SpliInstallStateUpdateListener를 Activity 생명 주기에 등록시킵니다.

 override fun onResume() {
    // Listener can be registered even without directly triggering a download.
    splitInstallManager.registerListener(listener)
    super.onResume()
}

override fun onPause() {
    // Make sure to dispose of the listener once it's no longer needed.
    splitInstallManager.unregisterListener(listener)
    super.onPause()
}

listener의 구현은 아래와 같습니다.

/** Listener used to handle changes in state for install requests. */
private val listener = SplitInstallStateUpdatedListener { state ->
    if (state.sessionId() != sessionId) {
        return@SplitInstallStateUpdatedListener
    }

    processInstallState(state)
}

private fun processInstallState(state: SplitInstallSessionState) {
   ...
}

먼저 session id를 확인한 후 넘겨받은 state를 처리합니다.

private fun processInstallState(state: SplitInstallSessionState) {
    when (state.status()) {
        SplitInstallSessionStatus.DOWNLOADING -> {
            val totalSize = state.totalBytesToDownload().toInt()
            val downloadSize = state.bytesDownloaded().toInt()
            val totalSizeKb = (totalSize.toFloat() / 1000f).toInt()
            val downloadSizeKb = (downloadSize.toFloat() / 1000f).toInt()
            LogDebug(TAG) { "Listener - downloading!! $downloadSizeKb / $totalSizeKb" }
            viewBinding.apply {
                moduleProgressGroup.visibility = View.VISIBLE
                moduleProgressSubtitle.text =
                        getString(R.string.downloading, "$downloadSizeKb", "$totalSizeKb")
                moduleProgress.max = totalSize
                moduleProgress.progress = downloadSize
                moduleProgressPercent.text =
                        getString(R.string.download_percent, "${(downloadSize.toFloat() / totalSize.toFloat() * 100f).toInt()}%")
            }
        }
        SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
            LogDebug(TAG) { "Listener - Uer Request Needed!" }
            // 다운로드 모듈의 크기가 큰 경우 사용자 동의를 받기위해 해당 state가 발생됩니다.
            splitInstallManager.startConfirmationDialogForResult(state, this, CONFIRMATION_REQUEST_CODE)
        }
        SplitInstallSessionStatus.INSTALLED -> {
            LogDebug(TAG) { "Listener - installed!!" }
            viewBinding.moduleProgressGroup.visibility = View.GONE
            startNewFeatureModule()
        }
        SplitInstallSessionStatus.INSTALLING -> {
            LogDebug(TAG) { "Listener - installing..." }
            viewBinding.moduleProgressSubtitle.setText(R.string.installing)
        }
        SplitInstallSessionStatus.FAILED -> {
            LogError(TAG) { "Listener - Install Failed!!" }
            toast(this, getString(R.string.error_for_module, state.errorCode(), state.moduleNames()))
        }
    }
}

DOWNLOADING 상태에서는 SplitInstallSessionState.totalBytesToDownlaod()SplitInstallSessionState.bytesDownloaded() 함수로 상태 정보를 받아 올 수 있습니다.

INSTALLED 가 호출되면 실제 수행해야 할 함수를 호출합니다.

REQUIRES_USER_CONFIRMATION은 다운로드의 크기가 큰 경우 미리 사용자에 팝업을 띄워 다운로드 여부를 물어봐야 합니다. 이때 팝업 내용으로 moudle의 title(strings.xml에 정의해 놓은) 이 표기됩니다.

따라서 아래와 같이 onActivityResult() 함수를 override 하여 적절한 처리를 하도록 합니다.

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (requestCode == CONFIRMATION_REQUEST_CODE) {
        // Handle the user's decision. For example, if the user selects "Cancel",
        // you may want to disable certain functionality that depends on the module.
        if (resultCode == Activity.RESULT_CANCELED) {
            LogError(TAG) { "onActivityResult() - install canceled" }
        } else {
            LogInfo(TAG) { "onActivityResult() - start install!!" }
        }
    } else {
        super.onActivityResult(requestCode, resultCode, data)
    }
}

이 값을 테스트해보기 위해서 60메가가 넘게 module을 만들어 봤으나 다운로드 확인 팝업이 뜨지 않았습니다. 아무래도 Dynamic feature module 이라기보다는 asset deliverry를 사용할 때 발생할 것으로 예상되네요.

다운로드 상태가 체크 가능하므로 아래와 같이 UI로 표시해 줄 수 있습니다.

FEATURE TEST 버튼을 click시 매번 downloadModule() 함수를 호출한다면 이미 다운로드한 모듈을 계속해서 요청하게 됩니다. 따라서 SplitInstallManager.installedModules를 통해서 아래와 같이 모듈 다운로드 여부를 확인할 수 있습니다.

viewBinding.startNewFeatureBtn.setOnClickListener {
    if (splitInstallManager.installedModules.contains(MODULE_NAME_NEW_FEATURE1)) {
        LogInfo(TAG) { "module is already download - $MODULE_NAME_NEW_FEATURE1" }
        startNewFeatureModule()
    } else {
        LogInfo(TAG) { "module is not exist. start download - $MODULE_NAME_NEW_FEATURE1" }
        downloadModule()
    }
}

만약 다운로드가 완료된 모듈을에 대하여 계속해서 startInstall을 요청한다면 재 다운로드 없이 바로 Listener의 INSTALLED 가 호출됩니다.

마지막으로 startNewFeatureModule() 함수는 다운로드한 Module의 Activity를 start 하는 함수입니다.

안타깝게도 Downloadable 한 Module의 Activity 정보는 complie time에는 base에서 알 수 없습니다. Activity 정보뿐만 아니라 Module의 어떤 정보도 모르는 상태입니다. 이는 Module이 다운로드 전이라면 Module의 Activity가 없는 상태에서 Base module만 존재할 수 있기 때문에 Module의 Activity에 접근하려면 class name으로 접근하여 start 해야 합니다.

private const val FEATURE_PACKAGE = BuildConfig.APPLICATION_ID + ".ondemand.feature1"
private const val ACTIVITY_CLASS_NAME = "$FEATURE_PACKAGE.Feature1MainActivity"

private fun startNewFeatureModule() {
    val intent = Intent().setClassName(BuildConfig.APPLICATION_ID, ACTIVITY_CLASS_NAME)
    startActivity(intent)
}

 

on-demand 형태로 다운로드되는 newFeature1 module에는 위와 같이 이미지 하나와, 문구 하나 있는 Activity가 뜹니다. 여기서 사용되는 String resource와 이미지는 전부 newFeature1 module에 존재합니다.

Module의 취소 / 삭제

다운로드 중인 모듈을 아래 명령어로 취소할 수 있습니다.

SplitInstallManager.cancelInstall("세션 ID")

또한 다운로드 받은 모듈을 deferredUninstall()을 이용하여 삭제할 수 있습니다. 예제에서 만들어 놓은 두 번째 버튼인 "FEATURE REMOVE"의 click listener는 아래와 같이 구현합니다.

viewBinding.removeNewFeature.setOnClickListener {
    if (splitInstallManager.installedModules.contains(MODULE_NAME_NEW_FEATURE1)) {
        splitInstallManager.deferredUninstall(listOf(MODULE_NAME_NEW_FEATURE1))
                .addOnSuccessListener {
                    toast(this, "Uninstalling $MODULE_NAME_NEW_FEATURE1")
                }.addOnFailureListener {
                    toast(this, "Failed uninstallation of $MODULE_NAME_NEW_FEATURE1")
                }
    } else {
        LogInfo(TAG) { "module is not exist." }        
    }
}

Deferred 이기 때문에 앱이 백그라운드에 들어갔을 때, 적절한 시점에 모듈이 삭제됩니다. 아쉽게도 바로 삭제하는 api는 존재하 지 않습니다. [4]

Play core KTX의 사용

SplitInstallManager에서 제공하는 API들은 KTX에서 대부분 suspend function으로 제공합니다. 위에서 언급했던 코드들을 KTX를 사용하여 변경하면 아래와 같습니다.[5]

startInstall() -> requestInstall()

 private fun downloadModule() {
        launch {
            LogDebug(TAG) { "request download START!" }
            splitInstallManager.requestInstall(listOf(MODULE_NAME_NEW_FEATURE1))
            LogDebug(TAG) { "request download END!" }
        }

        launch {
            splitInstallManager.requestProgressFlow().filter { state ->
                state.moduleNames.contains(MODULE_NAME_NEW_FEATURE1)
            }.collect { state ->
                processInstallState(state)
            }
        }
    }

앞서 startIsntallSplitInstallRequest를 인자로 넘겨받지만 requestInstall 함수는 인자로 module list (List <String>)를 넘겨받습니다. coroutine을 사용하는 형태이므로 다운로드가 끝날 때까지 함수가 block 되었으면 좋겠지만 startInstall 동작과 동일하게 다운로드 요청만 완료되면 바로 함수 호출은 완료됩니다.

그리고 KTX를 사용하면 listener를 resiter하지 않고 바로 requestProgressFlow() 함수를 통해 Flow로 넘겨받을 수 있습니다. 이때 넘어오는 state는 위에서 사용했던 state 처리 함수인 processInstallState와 동일하게 처리하면 됩니다.

정리

예제와 함께 모듈의 다운로드/삭제에 관련된 코드를 설명했습니다. 다운로드와 취소, 삭제 등에 관련된 API는 Play core library에서 잘 제공되어 있으며, 만날 수 있는 예외 사항도 적절한 Error code로 반환되도록 되어 있습니다. 사실 다른 모듈로 분리하지 않았다면 거치지 않았을 다운로드 관련 코드들과 상태 코드들로 인하여 코드의 복잡성은 늘어납니다. 아무리 잘 방어한다고 해도 다운로드 도중 갑자기 네트워크가 끊어진다거나, 구글 정보를 로그아웃해 버린다거나 등등의 생각지 못한 예외 사항들이 발생할 수 있다는 두려움도 존재합니다. (혹시라도 다운로드가 영영 안 받아지는 사용자가 있음 어떻게 하지.. 등등)

다운로드가 완료되면 모듈 간 서로 상호 호출할 수 있도록 하는 방법은 다음 포스팅에 다루겠지만, 위에서 activity 호출 시 class명으로(string 형태로) 호출하는 것처럼 서로가 base가 module의 상태를 모르는 상태에서 호출하기 위해 reflection을 써야만 하는 형태는 개인적으로는 살짝 불안하기도 합니다. 이외에도 모듈을 분할함에 따라 문제 되는 것들을 좀 더 고민해 보는 내용은 다음 포스팅에 담도록 하겠습니다.

 

References

[1] github.com/android/app-bundle-samples/tree/main/DynamicFeatures

[2] developer.android.com/guide/playcore

[3] developer.android.com/guide/playcore/play-feature-delivery

[4] developer.android.com/reference/com/google/android/play/core/splitinstall/SplitInstallManager#deferredinstall

[5] developer.android.com/reference/com/google/android/play/core/ktx/package-summary

반응형