Kotlin coroutine called two times instead of once in Compose function

Advertisements

I am developing an Android app with Kotlin, Jetpack Compose – for UI, and Retrofit – for making requests to a REST API server made by me. I am a beginner at Kotlin Coroutines, Compose and Retrofit and I am facing the following issue:

  • immediately after the HomeActivity starts, the assignSyncer() function in the HomeViewModel, which contains a coroutine that retrieves a Syncer object from the server via Retrofit, is called two times instead of once.

It does not produce any observable difference for the mobile user, but the server receives the request two times, and it is not ideal to burden the server and the network. I put some prints in the code and, indeed, the Retrofit call is executed two times – the code flows this way:

  1. enters HomeActivity (CP_1),
  2. enters assignSyncer() (CP_2),
  3. launches coroutine (CP_3),
  4. getInstance() is called (CP_4),
  5. and the instance is created (CP_5) and the REST API call is made.

But immediately after, again,

  1. CP_2,
  2. CP_3,
  3. and CP_4 are gone through.

The Syncer object is received correctly and it is integrated into the composable objects, even though this process happens twice.

Therefore, is it something I am doing wrong?

Here’s some relevant code:


The HomeActivity which uses Compose:

class HomeActivity() : ComponentActivity() {

    private val homeViewModel by viewModels<HomeViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        println("CP_1")
        setContent {
            AppyTheme {
                HomeFrame(syncer = homeViewModel.receivedSyncer)
                homeViewModel.assignSyncer()
            }
        }
    }
    
    @Composable fun HomeFrame(syncer: Syncer) {
    /* ... */

}

The HomeViewModel:

class HomeViewModel : ViewModel() {

    var receivedSyncer: Syncer by mutableStateOf(Syncer()) // Syncer() - initialises an empty Syncer object with default values for its fields

    var connectionError: Boolean by mutableStateOf(false)

    fun assignSyncer() {
        println("CP_2")
        viewModelScope.launch {
            try {
                println("CP_3")
                val api = APIService.getInstance()
                receivedSyncer = api.requestSyncer(0L, 0L)
            } catch (e: Exception) {
                println("CP_EXC - ${e.message}")
                connectionError = true
            }
        }
    }

}

The Retrofit call that receives a Syncer and the Retrofit instance:

interface APIService {

    @GET("base/sync/generate")
    suspend fun requestSyncer(
        @Query("fir-id") firstId: Long,
        @Query("sec-id") secondId: Long
    ): Syncer

    companion object {

        private const val BASE_URL = "http://192.168.1.4:8080/"

        private var apiService: APIService? = null

        fun getInstance(): APIService {
            println("CP_4")
            if (apiService == null) {
                println("CP_5")
                val gson = GsonBuilder().setLenient().create()
                val okHttpClient = OkHttpClient
                    .Builder()
                    .readTimeout(15, TimeUnit.SECONDS)
                    .connectTimeout(10, TimeUnit.SECONDS)
                    .build()
                apiService = Retrofit
                    .Builder()
                    .baseUrl(BASE_URL)
                    .client(okHttpClient)
                    .addConverterFactory(GsonConverterFactory.create(gson))
                    .build()
                    .create(APIService::class.java)
            }
            return apiService!!
        }
    }

}

The AndroidManifest file:

<!-- ... -->
<activity
    android:name=".ui.home.HomeActivity"
    android:exported="true"
    android:theme="@style/Theme.Appy.NoActionBar" >
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>
<!-- ... -->

Thank you!

>Solution :

Your call to homeViewModel.assignSyncer() is a part of composition. So whenever there is a recomposition this function gets called. For such side effects, you should use proper effect handlers. Like here you want to call this function only once so you can use LaunchedEffect (refer linked doc for detailed information about these functions).

setContent {
    AppyTheme {
        HomeFrame(syncer = homeViewModel.receivedSyncer)
        LaunchedEffect(Unit) {
            homeViewModel.assignSyncer()
        }
    }
}

But there is still one problem with this code that it will call assignSyncer after every configuration change. Probably the best place to call this function is the init block of HomeViewModel.

Leave a ReplyCancel reply