Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

iOS Lifecycle is destroyed for some reason only on certain devices when using camera. #185

Open
cj3g10 opened this issue Jan 3, 2025 · 9 comments
Labels
question Further information is requested

Comments

@cj3g10
Copy link

cj3g10 commented Jan 3, 2025

I have a problem that I need some assistance with, since I have no idea why this happens and my client isn't really being cooperative in assisting us debug this issue. I have a project that is using Decompose and iOS is using SwiftUI. The app is working fine for us, but when I gave this app to our client for UAT, one person is encountering a strange issue that I cannot replicate.
We have a screen where the user can select a picture or take a photo, and if they choose to take a photo, it will launch a new screen with a CameraView and the captured photo will be passed back to the 1st screen. This works fine for almost everyone, but for that one specific person's device, when they return to the first screen, none of the Component functions work.
Unfortunately debugging this has been hard because we do not have access to the only device that is reproducing this issue, and as mentioned, the client isn't exactly being cooperative and only tests it once a day at around midnight before they go to sleep. I've placed some logging in the app and what I find is that the Lifecycle inside the Component is in the Destroyed state upon returning to the first screen, which is why none of the Component's functions are responding as the coroutineScope is cancelled when the Lifecycle is destroyed. The Component's Lifecycle is not in the Destroyed state if the user does other actions such as using the photo picker, or going to an another screen and then returning back; it only specifically gets Destroyed after using the Camera.

Do you have any idea why the Lifecycle would get destroyed in this scenario? The device is an iPhone 11 using iOS 17.2, although I don't think this matters too much as we also tried with our own iPhone 11 using iOS 17.2.
How I can prevent this or alternatively is there anyway to restart the lifecycle?
Is there any more info you would like me to collect to help assist you?

Versions:
com.arkivanov.decompose:decompose:3.2.2
com.arkivanov.essenty:lifecycle:2.4.0

@arkivanov
Copy link
Owner

The Camera screen, is it a separate screen/app or a Decompose component? How do you manage the root lifecycle in iOS?

@arkivanov arkivanov added the question Further information is requested label Jan 3, 2025
@cj3g10
Copy link
Author

cj3g10 commented Jan 3, 2025

The Camera screen is a separate Decompose Content and Component in the same App.
The root lifecycle is using the experimental ApplicationLifecycle.

@arkivanov
Copy link
Owner

The Camera screen is a separate Decompose Content and Component in the same App. The root lifecycle is using the experimental ApplicationLifecycle.

In this case I recommend checking if the root Lifecycle doesn't gets destroyed for some reason. And also checking the navigation logic in the parent component responsible for navigation.

@cj3g10
Copy link
Author

cj3g10 commented Jan 3, 2025

In this case I recommend checking if the root Lifecycle doesn't gets destroyed for some reason.

What should I be checking here? What steps should I be taking whether I find that the RootComponent's Lifecycle is destroyed or not when compared to the Component with the destroyed Lifecycle (which is the SelfieComponent)?

And also checking the navigation logic in the parent component responsible for navigation.

This is what my RootContent looks like, with the two screens in question:

import Shared
import SwiftUI

struct RootContent: View {
    let root: RootComponent

    var body: some View {
        ZStack {
            stack
            dialog
        }
    }

    var stack: some View {
        StackView(
            stackValue: StateValue(root.stack),
            onBack: root.onBackClicked,
            childContent: {
                switch onEnum(of: $0) {
                // MARK: Common
                case .profilePictureCamera(let child): ProfilePictureCameraContent(component: child.component)
                ...
                // MARK: Selfie Module
                case .selfie(let child): SelfieContent(component: child.component)
                ...
                }
            }
        )
    }

    var dialog: some View {
        BottomSheet(
            slotValue: StateValue(root.dialog)
        ) {
            switch onEnum(of: $0) {
            ...
            }
        }
    }
}

The StackView is mostly the same as the one your Decompose repository, with 2 minor changes being that I removed the getTitle parameter and I set the NavigationStack views to have the toolbar hidden.

childContent(stack.first!.instance!)
  .toolbar(.hidden)
  .navigationDestination(for: Child<AnyObject, T>.self) {
    childContent($0.instance!)
      .toolbar(.hidden)
  }

@arkivanov
Copy link
Owner

You can add logs in your RootComponent as follows (replace println with whatever logging tool you are using):

    init {
        println("Root created")
        lifecycle.subscribe(
            onCreate = { println("Root onCreate") },
            onDestroy = { println("Root onDestroy") },
        )
    }

Also, it would be nice to see your RootComponent, if possible. That's the most interesting part.

@cj3g10
Copy link
Author

cj3g10 commented Jan 3, 2025

interface RootComponent {
    val stack: Value<ChildStack<*, Child>>

    val dialog: Value<ChildSlot<*, DialogChild>>

    fun onBackClicked(toIndex: Int)

    @SealedInterop.Enabled
    sealed class Child {
        //region Common
        data class ProfilePictureCamera(val component: ProfilePictureCameraComponent) : Child()
        //endregion

        ...

        //region Selfie Module
        data class Selfie(val component: SelfieComponent) : Child()
        //endregion

        ...
    }

    @SealedInterop.Enabled
    sealed class DialogChild(open val component: DialogComponent) {
        /region Common
        data class GenericError(override val component: GenericErrorDialogComponent) : DialogChild(component)

        data class ProfilePicture(override val component: ProfilePictureDialogComponent) : DialogChild(component)
        //endregion
        ...
    }
}

// @Single(binds = [RootComponent::class])
class DefaultRootComponent(
    componentContext: ComponentContext,
) : RootComponent,
    ComponentContext by componentContext,
    ErrorService,
    KoinComponent {
    //region Stack
    private val navigation = StackNavigation<Config>()
    override val stack: Value<ChildStack<*, RootComponent.Child>> =
        childStack(
            source = navigation,
            serializer = Config.serializer(),
            initialConfiguration = Config.Splash,
            handleBackButton = true,
            childFactory = ::child,
        )

    private fun child(
        config: Config,
        componentContext: ComponentContext,
    ): RootComponent.Child =
        when (config) {
            //region Common
            Config.ProfilePictureCamera -> {
                RootComponent.Child.ProfilePictureCamera(
                    profilePictureCameraComponent(componentContext),
                )
            }
            //endregion

            ...
            //region Selfie Module
            Config.Selfie ->
                RootComponent.Child.Selfie(
                    selfieComponent(componentContext),
                )
            //endregion
            ...
        }

    //region Common
    private fun profilePictureCameraComponent(componentContext: ComponentContext): ProfilePictureCameraComponent =
        get {
            parametersOf(
                componentContext, // componentContext: ComponentContext
                { image: PlatformImageType? ->
                    navigation.pop {
                        image?.let { image ->
                            when (val childComponent = stack.active.instance) {
                                is RootComponent.Child.SetupProfile -> {
                                    childComponent.component.setImage(image)
                                }
                                is RootComponent.Child.ProfileLanding -> {
                                    childComponent.component.saveProfilePicture(image)
                                }
                                is RootComponent.Child.Selfie -> {
                                    childComponent.component.setImage(image)
                                }
                                else -> {
                                    // // Do nothing, other screens should not show this dialog
                                }
                            }
                        }
                    }
                }, // popBackWithResult: () -> Unit
            )
        }
    //endregion

    ...
    //region Selfie Module
    private fun selfieComponent(componentContext: ComponentContext): SelfieComponent =
        get {
            parametersOf(
                componentContext, // componentContext: ComponentContext
                ::popBack, // popBack: () -> Unit
                { dialogNavigation.activate(DialogConfig.ProfilePicture) }, // showImagePickerDialog: () -> Unit
                { navigation.push(Config.ProfilePictureCamera) }, // navigateToCamera: () -> Unit
            )
        }
    //endregion
    ...

    private fun popBack() {
        navigation.pop()
    }

    override fun onBackClicked(toIndex: Int) {
        navigation.popTo(index = toIndex)
    }

    @Serializable
    private sealed interface Config {
        //region Common
        @Serializable
        data object ProfilePictureCamera : Config
        //endregion

        ...
        //region Selfie Module
        @Serializable
        data object Selfie : Config
        //endregion

        ...
    }
    //endregion

    //region Slot
    private val dialogNavigation = SlotNavigation<DialogConfig>()
    override val dialog: Value<ChildSlot<*, RootComponent.DialogChild>> =
        childSlot(
            source = dialogNavigation,
            serializer = DialogConfig.serializer(),
            handleBackButton = true,
            childFactory = ::dialogChild,
        )

    private fun dialogChild(
        dialogConfig: DialogConfig,
        componentContext: ComponentContext,
    ): RootComponent.DialogChild =
        when (dialogConfig) {
            //region Common
            DialogConfig.GenericError ->
                RootComponent.DialogChild.GenericError(
                    genericErrorDialogComponent(componentContext),
                )
            DialogConfig.ProfilePicture ->
                RootComponent.DialogChild.ProfilePicture(
                    profilePictureDialogComponent(componentContext),
                )
            //endregion
            ...
        }

    //region Common
    private fun genericErrorDialogComponent(componentContext: ComponentContext): GenericErrorDialogComponent =
        get {
            parametersOf(
                componentContext, // componentContext: AppComponentContext
                { dialogNavigation.dismiss() }, // onDismiss: () -> Unit
            )
        }

    private fun profilePictureDialogComponent(componentContext: ComponentContext): ProfilePictureDialogComponent =
        get {
            parametersOf(
                componentContext, // componentContext: ComponentContext
                {
                    dialogNavigation.dismiss {
                        when (val curComponent = stack.active.instance) {
                            is RootComponent.Child.SetupProfile -> {
                                curComponent.component.setDialogResult(ProfilePictureDialogResult.Camera)
                            }
                            is RootComponent.Child.ProfileLanding -> {
                                curComponent.component.setDialogResult(ProfilePictureDialogResult.Camera)
                            }
                            is RootComponent.Child.Selfie -> {
                                curComponent.component.setDialogResult(ProfilePictureDialogResult.Camera)
                            }
                            else -> {
                                // Do nothing, other screens should not show this dialog
                            }
                        }
                    }
                }, // onCameraClick: () -> Unit
                {
                    dialogNavigation.dismiss {
                        when (val curComponent = stack.active.instance) {
                            is RootComponent.Child.SetupProfile -> {
                                curComponent.component.setDialogResult(ProfilePictureDialogResult.PhotoSelector)
                            }
                            is RootComponent.Child.ProfileLanding -> {
                                curComponent.component.setDialogResult(ProfilePictureDialogResult.PhotoSelector)
                            }
                            is RootComponent.Child.Selfie -> {
                                curComponent.component.setDialogResult(ProfilePictureDialogResult.PhotoSelector)
                            }
                            else -> {
                                // Do nothing, other screens should not show this dialog
                            }
                        }
                    }
                }, // onPhotoSelectorClick: () -> Unit
                { dialogNavigation.dismiss() }, // onDismiss: () -> Unit
            )
        }
    //endregion
    ...

    override fun showGenericError() {
        dialogNavigation.activate(DialogConfig.GenericError)
    }

    @Serializable
    private sealed interface DialogConfig {
        //region Common
        @Serializable
        data object GenericError : DialogConfig

        @Serializable
        data object ProfilePicture : DialogConfig
        //endregion
        ...
    }
    //endregion
}

@arkivanov
Copy link
Owner

At first glance I don't see anything wrong. I suggest to start with adding lifecycle logs to RootComponent and to both child components and see what happens there. Also, I recommend checking that every child component receives correct instances of ComponentContext. Overall, the following logs might be helpful.

// In your RootComponent

    init {
        println("Root created: $componentContext")
        lifecycle.subscribe(
            onCreate = { println("Root onCreate") },
            onDestroy = { println("Root onDestroy") },
        )
    }

    ...

    private fun child(
        config: Config,
        componentContext: ComponentContext,
    ): RootComponent.Child =
        when (config) {
            ...
        }.also {
            println("New child: $config, $componentContext)
        }
// In your child components

    init {
        println("<Child Component Name> created: $componentContext")
        lifecycle.subscribe(
            onCreate = { println("<Child Component Name> onCreate") },
            onDestroy = { println("<Child Component Name> onDestroy") },
        )
    }

@cj3g10
Copy link
Author

cj3g10 commented Jan 6, 2025

I would assume that the RootComponent should be correct. After all, the app is working perfectly fine for all except for that one device. I will try and add some log on these lifecycle subscriptions, although it may be hard as I believe Firebase Crashlytics has some size limits on how much I can log for iOS.

Any idea why the Lifecycle may get destroyed though? Instead of the issue being the code, I would assume that the Camera is triggering something that causes the Lifecycle to think it has stopped.

@arkivanov
Copy link
Owner

Honestly I don't have any ideas for now. The only ways the previous component's lifecycle can be destroyed is:

  1. The parent (root) lifecycle is destroyed.
  2. The child component is removed from the stack.

You can also report stack traces from onDestroy callbacks to see the cause.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants