Android force update
모든 버전을 완벽하게 지원하는 서비스를 만들 수 있으면 좋겠지만 서비스가 성장함에 따라 하위 버전의 지원을 중단해야 하는 상황이 발생하는 것은 불가피한 일입니다.
원인은 다양하겠지만 대부분 다음의 이유로 앱 업데이트를 유도해야 할 것입니다.
- 버그 발생: 앱 사용이나 비즈니스와 관련된 크리티컬한 버그가 포함된 버전이 출시된 경우 빠르게 패치 버전을 릴리즈하고 강제 업데이트를 해야합니다.
- 정책 변경: 비즈니스의 정책이 변경되어 더이상 이전 버전과 호환되지 않는 경우에도 강제 업데이트가 필요할 수 있습니다.
- 서버 API 변동: 서버 역시 서비스의 성장에 따라 변경될 수 있습니다. 보통 v1, v2와 같이 버저닝을 하는데 큰 문제가 없는 이상 하위 버전이 호환되도록 지원하지만 이전 버전의 API를 사용하는 앱 사용자가 기대 수치 이하로 떨어지면 서버에서 이전 버전의 지원을 중단하기 이전에 앱 강제 업데이트가 필요합니다.
버저닝
일반적으로 많이 사용하는 버저닝은 1.10.8과 같이 Major.Minor.Patch 형태로 구성됩니다.
추가적으로 0.2.0-alpha, 5.9-beta.3 같은 형태로 postfix나 prefix가 붙는 경우도 있지만 관리하기 편한 구조로 채택하면 됩니다.
이 포스트의 예시에서는 prefix, postfix 없이 숫자와 .으로만 이루어진 버저닝을 사용하겠습니다.
버전 확인 위치
강제 업데이트를 위해서는 지원하는 최소 버전과 현재 앱 버전을 비교해야 하는데 버전을 비교하는 위치를 잘 고려하지 않으면 특정 상황에서 강제 업데이트 로직이 무력화 될 수 있습니다.
대표적으로 스플래시 화면에서만 버전 비교 로직을 심어두고 노티피케이션이나 딥링크 등을 통해 스플래시 화면을 거치지 않고 앱에 진입할 수 있는 경우와 같이 다중 앱 진입 경로를 지원하는 경우 주의가 필요합니다.
추가적으로 업데이트 다이얼로그를 표시한 후 사용자가 실제로 업데이트를 진행하고 돌아온 경우에는 다시 한 번 버전을 확인할 수 있도록 해야합니다.
RemoteConfig를 사용하여 최소 버전 가져오기
자체 서버가 있다면 자체 서버를 이용해 최소 버전을 가져올 수도 있지만 예제에서는 간단히 Firebase의 RemoteConfig를 사용하겠습니다.
RemoteConfig를 소개하는 글은 아니기에 자세한 내용은 공식 문서를 참고하세요.
콘솔에서 프로젝트를 만들고 RemoteConfig를 설정했습니다. 키는 MinVersion으로 했습니다.
RemoteConfigDataSource
class RemoteConfigDataSource(
private val remoteConfig: FirebaseRemoteConfig,
) {
suspend fun getValue(key: String): FirebaseRemoteConfigValue? = suspendCoroutine { continuation ->
remoteConfig.fetchAndActivate().addOnCompleteListener { task ->
if (task.isSuccessful) {
continuation.resume(remoteConfig[key].also {
Log.d("MANGBAAM-RemoteConfigDataSource(getValue)", it.asString())
})
} else {
continuation.resume(null)
}
}
}
suspend fun getString(key: String, defaultValue: String): String = getValue(key)?.asString() ?: defaultValue
}RemoteConfig로부터 값을 가져오는 데이터소스를 작성했습니다. getBoolean, getInt 등 다양한 메서드를 구현할 수 있지만 이번 예제에서는 최소 버전(문자열)만을 가져올 것이라서 getString만 구현했습니다.
RemoteConfig에서 값을 가져오는 것은 비동기로 동작하기 때문에 suspend 키워드와 suspendCoroutine 블록을 사용한 중단 함수로 작성했습니다.
버전 비교
fun checkShouldUpdate(currentVersion: String, minVersion: String): Boolean {
if (!Regex("""^\d+\.\d+(\.\d+)?$""").matches(currentVersion)) return false
if (!Regex("""^\d+\.\d+(\.\d+)?$""").matches(minVersion)) return false
return currentVersion.split('.').map(String::toInt).let {
val min = minVersion.split('.').map(String::toInt)
!KotlinVersion(it[0], it[1], it.getOrNull(2) ?: 0)
.isAtLeast(min[0], min[1], min.getOrNull(2) ?: 0)
}
}버전 비교 로직은 위와 같이 작성했습니다.
currentVersion과 minVersion을 받아서 약속된 형태의 버전인지 확인합니다. 버저닝 형태가 일치하지 않을 때 처리는 정책에 맞게 처리하거나 로깅을 통해 개발자가 인지할 수 있는 형태로 개발하는 것이 안전하지만 예제에서는 false를 반환하도록 하겠습니다.
버전 비교 로직은 비교적 단순하기 때문에 직접 작성해도 되고, 저는 KotlinVersion 이라는 kotlin 패키지에 포함된 클래스의 isAtLeast 메서드를 활용했습니다.
여기서 자주 하는 실수는 .으로 쪼개서 비교할 때 정수로 변환하지 않고 비교하는 것입니다. 정수로 변환하지 않으면 사전 순으로 비교하게 되며 앱 버전이 1.10.0 이고 최소 버전을 1.9.0으로 설정했을 때 사전 순으로는 1.9.0이 더 큰 버전이므로 문제가 발생할 수 있습니다.
정수로 타입 변환해서 비교하고 있는지 꼭 확인하세요.
libs.versions.toml
[versions]
versionName = "1.0.0"
minSdk = "26"
targetSdk = "34"
versionCode = "1"
packageName = "com.mangbaam.forceupdateexample"
# ...libs.versions.toml에 versionName을 정의했습니다.
Get Mangbaam’s stories in your inbox
Join Medium for free to get updates from this writer.
packageName은 플레이스토어로 이동할 때 사용할 예정입니다.
build.gradle.kts
android {
// ...
defaultConfig {
// ...
buildConfigField("String", "APP_VERSION", "\"${libs.versions.versionName.get()}\"")
buildConfigField("String", "PACKAGE_NAME", "\"${libs.versions.packageName.get()}\"")
}
buildFeatures {
compose = true
buildConfig = true
}
// ...
}libs.versions.toml에 정의된 versionName과 packageName을 가져오기 위해 buildConfig를 사용했습니다.
BuildConfig.APP_VERSION, BuildConfig.PACKAGE_NAME과 같이 가져올 수 있습니다. (값은 새로 정의하거나 변경한 후에는 rebuild가 필요합니다)
ConfigRepository
class ConfigRepository(
private val remoteConfigDataSource: RemoteConfigDataSource,
) {
fun shouldUpdate(): Flow<Boolean> = flow {
remoteConfigDataSource.getString(KEY_MIN_VERSION, "0.0.0").let { minVersion ->
val currentVersion = BuildConfig.APP_VERSION
Log.d("mangbaam_ConfigRepository", "checkMinVersion: current: $currentVersion, min: $minVersion")
emit(checkShouldUpdate(currentVersion, minVersion))
}
}
companion object {
const val KEY_MIN_VERSION = "MinVersion"
}
}Flow 형태로 값을 가져오기 위해 flow 블록을 사용했습니다.
위에서 작성한 checkShouldUpdate 함수로 버전을 비교한 결과를 방출합니다.
MainActivity
class MainActivity : ComponentActivity() {
private val remoteConfig = FirebaseRemoteConfig.getInstance().apply {
val configSettings by lazy {
remoteConfigSettings {
minimumFetchIntervalInSeconds = if (com.google.firebase.remoteconfig.BuildConfig.DEBUG) 0 else 60
}
}
setConfigSettingsAsync(configSettings)
}
private val dataSource = RemoteConfigDataSource(remoteConfig)
private val repository = ConfigRepository(dataSource)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val shouldUpdate by repository.shouldUpdate().collectAsStateWithLifecycle(
initialValue = null,
minActiveState = Lifecycle.State.STARTED,
)
// ...
}
}RemoteConfig와 위에서 작성한 RemoteConfigDataSource,를 사용해 ConfigRepository 객체를 만들고 collectAsStateWithLifecycle()을 사용해서 컴포즈의 State으로 변환했습니다.
실제 프로젝트에서는 Hilt와 같은 DI 라이브러리와 AAC ViewModel 등을 사용하면 훨씬 깔끔하게 관리할 수 있습니다.
collectAsState가 아닌 collectAsStateWithLifecycle을 사용한 이유는 사용자가 업데이트를 하고 앱으로 복귀했을 때 다시 버전 체크를 해야하기 때문입니다.
UI 작업
다이얼로그
@Composable
fun ForceUpdateDialog(
modifier: Modifier = Modifier,
onClickUpdate: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
),
title = {
Text(text = "업데이트가 필요합니다.")
},
text = {
Text(text = "원활한 서비스 이용을 위해 최신 버전으로 업데이트가 필요합니다.")
},
confirmButton = {
TextButton(onClick = onClickUpdate) {
Text(
text = "업데이트 하기",
style = MaterialTheme.typography.labelMedium,
)
}
},
)
}다이얼로그 UI는 서비스의 디자인에 맞게 작성하면 되지만 강제 업데이트인 만큼 쉽게 닫히지 않도록 해야합니다.
저는 다이얼로그 바깥 쪽을 누르는 것은 방지하고, 백 버튼을 눌렀을 때는 앱이 종료되도록 작성했습니다.
플레이스토어로 보내기
다이얼로그의 업데이트 하기 버튼을 누르면 플레이스토어로 보내는 동작이 일반적입니다.
val playStoreUrl =
"http://play.google.com/store/apps/details?id=${BuildConfig.PACKAGE_NAME}"
startActivity(
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(playStoreUrl)
setPackage("com.android.vending")
}
)플레이스토어 주소는 앱 패키지 명과의 조합으로 만들어지므로 위와 같이 플레이스토어 주소를 가져올 수 있습니다.
MainActivity 전체 코드
class MainActivity : ComponentActivity() {
private val remoteConfig = FirebaseRemoteConfig.getInstance().apply {
val configSettings by lazy {
remoteConfigSettings {
minimumFetchIntervalInSeconds = if (com.google.firebase.remoteconfig.BuildConfig.DEBUG) 0 else 60
}
}
setConfigSettingsAsync(configSettings)
}
private val dataSource = RemoteConfigDataSource(remoteConfig)
private val repository = ConfigRepository(dataSource)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val shouldUpdate by repository.shouldUpdate().collectAsStateWithLifecycle(
initialValue = null,
minActiveState = Lifecycle.State.STARTED,
)
ForceUpdateExampleTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
when (shouldUpdate) {
true -> ForceUpdateDialog(
onDismiss = {
// 업데이트하지 않고 다이얼로그를 닫은 경우 앱을 종료한다.
finish()
},
onClickUpdate = {
val playStoreUrl =
"http://play.google.com/store/apps/details?id=${BuildConfig.PACKAGE_NAME}"
startActivity(
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(playStoreUrl)
setPackage("com.android.vending")
}
)
},
)
false -> Main(modifier = Modifier.padding(innerPadding))
null -> Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
}
}
}
@Composable
fun Main(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
text = "Welcome!",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.displayLarge,
)
}
}
@Composable
fun ForceUpdateDialog(
modifier: Modifier = Modifier,
onClickUpdate: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
),
title = {
Text(text = "업데이트가 필요합니다.")
},
text = {
Text(text = "원활한 서비스 이용을 위해 최신 버전으로 업데이트가 필요합니다.")
},
confirmButton = {
TextButton(onClick = onClickUpdate) {
Text(
text = "업데이트 하기",
style = MaterialTheme.typography.labelMedium,
)
}
},
)
}






