Google은 흑인 공동체를 위한 인종 간 평등을 진전시키기 위해 노력하고 있습니다. Google에서 어떤 노력을 하고 있는지 확인하세요.

조건부 탐색

앱 탐색을 설계할 때는 조건부 로직에 따라 다른 대상과 대조하여 한 대상으로 이동하는 것이 좋습니다. 예를 들어 사용자 로그인이 필요한 일부 대상이 있거나 게임에서 플레이어의 승패에 따라 이동하는 여러 대상이 있을 수도 있습니다.

사용자 로그인

이 예에서는 사용자가 인증이 필요한 프로필 화면으로 이동하려고 합니다. 이 작업은 인증이 필요하므로 아직 인증되지 않은 사용자를 로그인 화면으로 리디렉션해야 합니다.

이 예의 탐색 그래프는 다음과 같을 수 있습니다.

그림 1: 로그인 흐름은 앱의 기본 탐색 흐름과 별도로 처리됩니다.

인증하려면 앱에서 사용자가 사용자 이름과 비밀번호를 입력하여 인증을 받을 수 있는 login_fragment로 이동해야 합니다. 사용자 이름과 비밀번호가 승인되면 사용자를 다시 profile_fragment 화면으로 보냅니다. 승인되지 않으면 Snackbar를 사용해 사용자에게 사용자 인증 정보가 잘못되었다고 알립니다.

이 앱에 포함된 대상은 단일 활동으로 호스팅되는 프래그먼트를 사용하여 표시됩니다.

이 앱의 탐색 그래프는 다음과 같습니다.

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/nav_graph"
        app:startDestination="@id/main_fragment">

    <fragment
            android:id="@+id/main_fragment"
            android:name="com.google.android.conditionalnav.MainFragment"
            android:label="fragment_main"
            tools:layout="@layout/fragment_main">

        <action
                android:id="@+id/navigate_to_profile_fragment"
                app:destination="@id/profile_fragment"/>
    </fragment>

    <fragment
            android:id="@+id/login_fragment"
            android:name="com.google.android.conditionalnav.LoginFragment"
            android:label="login_fragment"
            tools:layout="@layout/login_fragment"/>

    <fragment
            android:id="@+id/profile_fragment"
            android:name="com.google.android.conditionalnav.ProfileFragment"
            android:label="fragment_profile"
            tools:layout="@layout/fragment_profile"/>

</navigation>

MainFragment에는 프로필을 보려는 사용자가 클릭할 수 있는 버튼이 있습니다. 사용자가 프로필 화면을 보려면 먼저 인증을 받아야 합니다. 이 상호작용은 두 개의 개별 프래그먼트를 사용하여 모델링되지만 공유 상태(사용자가 인증되었는지 여부)와 인증된 경우 인증된 사용자 이름에 따라 다릅니다. 이 상태 정보는 이 두 프래그먼트 중 하나의 책임이 아니며 다음 예에서와 같이 공유 ViewModel에 더 적절하게 보관됩니다.

Kotlin

class LoginViewModel : ViewModel() {
    enum class AuthenticationState {
        UNAUTHENTICATED,        // Initial state, the user needs to authenticate
        AUTHENTICATED  ,        // The user has authenticated successfully
        INVALID_AUTHENTICATION  // Authentication failed
    }

    val authenticationState = MutableLiveData<AuthenticationState>()
    var username: String

    init {
        // In this example, the user is always unauthenticated when MainActivity is launched
        authenticationState.value = AuthenticationState.UNAUTHENTICATED
        username = ""
    }

    fun refuseAuthentication() {
        authenticationState.value = AuthenticationState.UNAUTHENTICATED
    }

    fun authenticate(username: String, password: String) {
        if (passwordIsValidForUsername(username, password)) {
            this.username = username
            authenticationState.value = AuthenticationState.AUTHENTICATED
        } else {
            authenticationState.value = AuthenticationState.INVALID_AUTHENTICATION
        }
    }

    private fun passwordIsValidForUsername(username: String, password: String): Boolean {
        ...
    }
}

자바

public class LoginViewModel extends ViewModel {

    public enum AuthenticationState {
        UNAUTHENTICATED,        // Initial state, the user needs to authenticate
        AUTHENTICATED,          // The user has authenticated successfully
        INVALID_AUTHENTICATION  // Authentication failed
    }

    final MutableLiveData<AuthenticationState> authenticationState =
            new MutableLiveData<>();
    String username;

    public LoginViewModel() {
        // In this example, the user is always unauthenticated when MainActivity is launched
        authenticationState.setValue(AuthenticationState.UNAUTHENTICATED);
        username = "";
    }

    public void authenticate(String username, String password) {
        if (passwordIsValidForUsername(username, password)) {
            this.username = username;
            authenticationState.setValue(AuthenticationState.AUTHENTICATED);
        } else {
            authenticationState.setValue(AuthenticationState.INVALID_AUTHENTICATION);
        }
    }

    public void refuseAuthentication() {
       authenticationState.setValue(AuthenticationState.UNAUTHENTICATED);
    }

    private boolean passwordIsValidForUsername(String username, String password) {
        ...
    }
}

ViewModelViewModelStoreOwner로 범위가 지정됩니다. ViewModelStoreOwner를 구현하는 활동으로 ViewModel의 범위를 지정하여 프래그먼트 간에 데이터를 공유할 수 있습니다. 다음 예에서는 MainActivity에서 ProfileFragment를 호스팅하므로 requireActivity()MainActivity로 확인됩니다.

Kotlin

class LoginFragment : Fragment() {

    private val viewModel: LoginViewModel by activityViewModels()
    ...
}

자바

public class LoginFragment extends Fragment {

    private LoginViewModel viewModel;

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        viewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class);
        ...
    }
    ...
}

사용자의 인증 상태는 LoginViewModel에서 enum 클래스로 나타나고 LiveData를 통해 노출되므로 이동할 위치를 결정하려면 이 인증 상태를 관찰해야 합니다. ProfileFragment로 이동하면 사용자가 인증된 경우 앱에서 환영 메시지를 표시합니다. 사용자가 프로필을 보기 전에 인증되어야 하므로 인증되지 않은 경우 LoginFragment로 이동합니다. 다음 예에서와 같이 ViewModel에 결정 로직을 정의해야 합니다.

Kotlin

class ProfileFragment : Fragment() {

    private val viewModel: LoginViewModel by activityViewModels()

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        welcomeTextView = view.findViewById(R.id.welcome_text_view)

        val navController = findNavController()
        viewModel.authenticationState.observe(viewLifecycleOwner, Observer { authenticationState ->
            when (authenticationState) {
                AUTHENTICATED -> showWelcomeMessage()
                UNAUTHENTICATED -> navController.navigate(R.id.login_fragment)
            }
        })
    }

    private fun showWelcomeMessage() {
        ...
    }
}
...

자바

public class ProfileFragment extends Fragment {

    private LoginViewModel viewModel;

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        viewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class);

        welcomeTextView = view.findViewById(R.id.welcome_text_view);

        final NavController navController = Navigation.findNavController(view);
        viewModel.authenticationState.observe(getViewLifecycleOwner(),
                new Observer<LoginViewModel.AuthenticationState>() {
            @Override
            public void onChanged(LoginViewModel.AuthenticationState authenticationState) {
                switch (authenticationState) {
                    case AUTHENTICATED:
                        showWelcomeMessage();
                        break;
                    case UNAUTHENTICATED:
                        navController.navigate(R.id.loginFragment);
                        break;
                }
            }
        });
    }

    private void showWelcomeMessage() {
        ...
    }
    ...
}

사용자가 ProfileFragment에 도달할 때 인증된 상태가 아닌 경우 LoginFragment로 이동합니다. 거기에서 사용자는 사용자 이름과 비밀번호를 입력할 수 있으며 입력 후 LoginViewModel로 전달됩니다.

인증에 성공하면 ViewModel에서 인증 상태를 AUTHENTICATED로 설정합니다. 이에 따라 LoginFragment가 백 스택에서 삭제되고 사용자가 다시 ProfileFragment로 이동하게 됩니다. 사용자 인증 정보가 잘못되어 인증에 실패하면 상태가 INVALID_AUTHENTICATION으로 설정되고 LoginFragmentSnackbar가 사용자에게 표시됩니다. 마지막으로 사용자가 뒤로 버튼을 누르면 상태가 UNAUTHENTICATED로 설정되고 스택이 다시 MainFragment에 표시됩니다.

Kotlin

class LoginFragment : Fragment() {

    private val viewModel: LoginViewModel by activityViewModels()

    private lateinit var usernameEditText: EditText
    private lateinit var passwordEditText: EditText
    private lateinit var loginButton: Button
    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        usernameEditText = view.findViewById(R.id.username_edit_text)
        passwordEditText = view.findViewById(R.id.password_edit_text)

        loginButton = view.findViewById(R.id.login_button)
        loginButton.setOnClickListener {
            viewModel.authenticate(usernameEditText.text.toString(),
                    passwordEditText.text.toString())
        }

        requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
            viewModel.refuseAuthentication()
            navController.popBackStack(R.id.main_fragment, false)
        })

        val navController = findNavController()
        viewModel.authenticationState.observe(viewLifecycleOwner, Observer { authenticationState ->
            when (authenticationState) {
                AUTHENTICATED -> navController.popBackStack()
                INVALID_AUTHENTICATION -> showErrorMessage()
            }
        })
    }

    private void showErrorMessage() {
        ...
    }
}

자바

public class LoginFragment extends Fragment {

    private LoginViewModel viewModel;

    private EditText usernameEditText;
    private EditText passwordEditText;
    private Button loginButton;

    ...

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        viewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class);

        usernameEditText = view.findViewById(R.id.username_edit_text);
        passwordEditText = view.findViewById(R.id.password_edit_text);

        loginButton = view.findViewById(R.id.login_button);
        loginButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                viewModel.authenticate(usernameEditText.getText().toString(),
                        passwordEditText.getText().toString());
            }
        });

        requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(),
            new OnBackPressedCallback(true) {
                @Override
                public void handleOnBackPressed() {
                    viewModel.refuseAuthentication();
                    navController.popBackStack(R.id.main_fragment, false);
                }
            });

        final NavController navController = Navigation.findNavController(view);
        final View root = view;
        viewModel.authenticationState.observe(getViewLifecycleOwner(),
                new Observer<LoginViewModel.AuthenticationState>() {
            @Override
            public void onChanged(LoginViewModel.AuthenticationState authenticationState) {
                switch (authenticationState) {
                    case AUTHENTICATED:
                        navController.popBackStack();
                        break;
                    case INVALID_AUTHENTICATION:
                        Snackbar.make(root,
                                R.string.invalid_credentials,
                                Snackbar.LENGTH_SHORT
                                ).show();
                        break;
                }
            }
        });
    }
}

인증과 관련된 모든 로직은 LoginViewModel 내에 보관됩니다. 사용자가 인증되는 방법을 결정하는 것은 LoginFragment 또는 ProfileFragment의 책임이 아니므로 이는 중요합니다. 로직을 ViewModel로 캡슐화하면 공유하기 쉬울 뿐 아니라 테스트도 쉬워집니다. 탐색 로직이 복잡하면 특별히 테스트를 통해 이 로직을 확인해야 합니다. 테스트 가능한 구성요소를 중심으로 앱 아키텍처를 구성하는 방법을 자세히 알아보려면 앱 아키텍처 가이드를 참조하세요.

사용자가 ProfileFragment로 돌아오면 인증 상태가 다시 확인됩니다. 이제 사용자 인증이 완료되면 다음 예에서와 같이 인증된 사용자 이름을 사용하여 앱에서 환영 메시지를 표시합니다.

Kotlin

class ProfileFragment : Fragment() {

    private val viewModel: LoginViewModel by activityViewModels()

    private lateinit var welcomeTextView: TextView

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        welcomeTextView = view.findViewById(R.id.welcome_text_view)

        val navController = findNavController()
        viewModel.authenticationState.observe(viewLifeycleOwner, Observer { authenticationState ->
            when (authenticationState) {
                AUTHENTICATED -> showWelcomeMessage()
                UNAUTHENTICATED -> navController.navigate(R.id.loginFragment)
        })
    }

    private fun showWelcomeMessage() {
        welcomeTextView.text = getString(R.string.welcome, viewModel.username)
    }

    ...
}

자바

public class ProfileFragment extends Fragment {

    private LoginViewModel viewModel;

    private TextView welcomeTextView;

    ...

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        viewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class);

        welcomeTextView = view.findViewById(R.id.welcome_text_view);

        final NavController navController = Navigation.findNavController(view);
        viewModel.authenticationState.observe(getViewLifecycleOwner(),
                new Observer<LoginViewModel.AuthenticationState>() {
            @Override
            public void onChanged(LoginViewModel.AuthenticationState authenticationState) {
                switch (authenticationState) {
                    case AUTHENTICATED:
                        showWelcomeMessage();
                        break;
                    case UNAUTHENTICATED:
                        navController.navigate(R.id.loginFragment);
                        break;
                }
            }
        });
    }

    private void showWelcomeMessage() {
        welcomeTextView.setText(getString(R.string.welcome, viewModel.username));
    }
    ...
}

모든 탐색 작업이 조건을 기반으로 하지는 않지만, 조건을 기반으로 하는 작업에는 이 패턴이 유용할 수 있습니다. 탐색 조건을 정의하고 프래그먼트 간 커뮤니케이션을 위해 ViewModel에 공유된 소스 저장소를 제공하여 사용자가 앱을 탐색하는 방법을 결정합니다.

처음 사용하는 사용자 환경

처음 사용하는 사용자 환경(FTUE)은 사용자가 앱을 처음 실행할 때만 사용자에게 표시되는 특정 흐름입니다. 이 흐름을 앱의 기본 탐색 그래프의 일부로 만드는 대신 별도의 중첩된 탐색 그래프로 유지해야 합니다.

이전 섹션의 로그인 예를 바탕으로 다음 그림의 REGISTER 버튼으로 표시된 것처럼 로그인이 없는 사용자가 등록하는 시나리오가 있을 수 있습니다.

그림 2: 이제 로그인 화면에 REGISTER 버튼이 있습니다.

사용자가 REGISTER 버튼을 클릭하면 등록과 관련된 하위 탐색 흐름으로 이동합니다. 등록하면 백 스택이 표시되고 사용자가 곧바로 프로필 화면으로 이동됩니다.

아래 예에서 탐색 그래프는 중첩된 탐색 그래프를 포함하도록 업데이트되었습니다. 또한 login_fragment에 작업이 추가되었으며 REGISTER를 탭하면 이에 응답하여 작업을 트리거할 수 있습니다.

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/nav_graph"
        app:startDestination="@id/main_fragment">

    ...

    <fragment
            android:id="@+id/login_fragment"
            android:name="com.google.android.conditionalnav.LoginFragment"
            android:label="login_fragment"
            tools:layout="@layout/fragment_login">

        <action android:id="@+id/action_login_fragment_to_register_fragment"
                app:destination="@id/registration_graph" />

    </fragment>

    <navigation android:id="@+id/registration_graph"
            app:startDestination="@id/enter_user_profile_fragment">

        <fragment android:id="@+id/enter_user_profile_fragment"
                android:name="com.google.android.conditionalnav.registration.EnterProfileDataFragment"
                android:label="Enter Profile Data"
                tools:layout="@layout/fragment_enter_profile_info">

            <action android:id="@+id/move_to_choose_user_password"
                    app:destination="@id/choose_user_password_fragment" />

        </fragment>

        <fragment android:id="@+id/choose_user_password_fragment"
                android:name="com.google.android.conditionalnav.registration.ChooseUserPasswordFragment"
                android:label="Choose User + Password"
                tools:layout="@layout/fragment_choose_user_password" />

    </navigation>
</navigation>

탐색 편집기에 시각적으로 표현된 이 중첩 그래프는 그림 3에 표시된 것처럼 더 작은 중첩 그래프와 상단의 registration_graph ID로 표시됩니다.

그림 3: 이제 탐색 그래프에 중첩된 registration_graph가 표시됩니다.

편집기에서 Nested Graph를 더블클릭하면 등록 그래프의 세부정보가 표시됩니다. 그림 4에는 두 화면으로 구성된 등록 흐름이 표시됩니다. 첫 번째 화면에서는 사용자의 성명과 약력 정보를 수집합니다. 두 번째 화면에서는 원하는 사용자 이름과 비밀번호를 캡처합니다. 기본 탐색 그래프로 돌아가려면 Destinations 창에서 ← Root를 클릭합니다.

그림 4: 중첩 그래프에서 등록 흐름을 보여줍니다.

중첩된 탐색 그래프의 시작 대상은 Enter Profile Data 화면입니다. 사용자가 프로필 데이터를 입력하고 NEXT 버튼을 클릭하면 앱이 Create Login Credentials 화면으로 이동합니다. 사용자 이름과 비밀번호를 만들면 REGISTER + LOGIN을 클릭하여 프로필 화면으로 곧바로 이동할 수 있습니다.

앞의 예에서와 같이 ViewModel은 등록 프래그먼트 간에 정보를 공유하는 데 사용됩니다.

Kotlin

class RegistrationViewModel : ViewModel() {

    enum class RegistrationState {
        COLLECT_PROFILE_DATA,
        COLLECT_USER_PASSWORD,
        REGISTRATION_COMPLETED
    }

    val registrationState =
            MutableLiveData<RegistrationState>(RegistrationState.COLLECT_PROFILE_DATA)

    // Simulation of real-world scenario, where an auth token may be provided as
    // an alternate authentication mechanism instead of passing the password
    // around. This is set at the end of the registration process.
    var authToken = ""
        private set

    fun collectProfileData(name: String, bio: String) {
        // ... validate and store data

        // Change State to collecting username and password
        registrationState.value = RegistrationState.COLLECT_USER_PASSWORD
    }

    fun createAccountAndLogin(username: String, password: String) {
        // ... create account
        // ... authenticate
        this.authToken = // token

        // Change State to registration completed
        registrationState.value = RegistrationState.REGISTRATION_COMPLETED
    }

    fun userCancelledRegistration() : Boolean {
        // Clear existing registration data
        registrationState.value = RegistrationState.COLLECT_PROFILE_DATA
        authToken = ""
        return true
    }

}

자바

public class RegistrationViewModel extends ViewModel {

    enum RegistrationState {
        COLLECT_PROFILE_DATA,
        COLLECT_USER_PASSWORD,
        REGISTRATION_COMPLETED
    }

    private MutableLiveData<RegistrationState> registrationState =
            new MutableLiveData<>(RegistrationState.COLLECT_PROFILE_DATA);

    public MutableLiveData<RegistrationState> getRegistrationState() {
        return registrationState;
    }

    // Simulation of real-world scenario, where an auth token may be provided as
    // an alternate authentication mechanism instead of passing the password
    // around. This is set at the end of the registration process.
    private String authToken;

    public String getAuthToken() {
        return authToken;
    }

    public void collectProfileData(String name, String bio) {
        // ... validate and store data

        // Change State to collecting username and password
        registrationState.setValue( RegistrationState.COLLECT_USER_PASSWORD);
    }

    public void createAccountAndLogin(String username, String password) {
        // ... create account
        // ... authenticate
        this.authToken = // token

        // Change State to registration completed
        registrationState.setValue(RegistrationState.REGISTRATION_COMPLETED);
    }

    public boolean userCancelledRegistration() {
        // Clear existing registration data
        registrationState.setValue(RegistrationState.COLLECT_PROFILE_DATA);
        authToken = "";
        return true;
    }

}

ViewModel의 등록 상태는 각 등록 화면의 프래그먼트에서 관찰됩니다. 이 상태는 다음 화면으로 이동하도록 만들고 사용자 상호작용에 따라 RegistrationViewModel에 의해 업데이트됩니다. 언제든지 뒤로를 누르면 등록 절차가 취소되고 사용자가 다시 로그인 화면으로 돌아옵니다.

Kotlin

class EnterProfileDataFragment : Fragment() {

    val registrationViewModel by activityViewModels<RegistrationViewModel>()

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

        val navController = findNavController()

        ...

        // When the next button is clicked, collect the current values from the
        // two edit texts and pass to the ViewModel to store.
        view.findViewById<Button>(R.id.next_button).setOnClickListener {
            val name = fullnameEditText.text.toString()
            val bio = bioEditText.text.toString()
            registrationViewModel.collectProfileData(name, bio)
        }

        // RegistrationViewModel will update the registrationState to
        // COLLECT_USER_PASSWORD when ready to move to the choose username and
        // password screen.
        registrationViewModel.registrationState.observe(
                viewLifecycleOwner, Observer { state ->
                    if (state == COLLECT_USER_PASSWORD) {
                        navController.navigate(R.id.move_to_choose_user_password)
                    }
                })

        // If the user presses back, cancel the user registration and pop back
        // to the login fragment. Since this ViewModel is shared at the activity
        // scope, its state must be reset so that it will be in the initial
        // state if the user comes back to register later.
        requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
            registrationViewModel.userCancelledRegistration()
            navController.popBackStack(R.id.login_fragment, false)
        })
    }
}

class ChooseUserPasswordFragment : Fragment() {

    private val loginViewModel: LoginViewModel by activityViewModels()
    private val registrationViewModel: RegistrationViewModel by activityViewModels()

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

        val navController = findNavController()

        ...

        // When the register button is clicked, collect the current values from
        // the two edit texts and pass to the ViewModel to complete registration.
        view.findViewById<Button>(R.id.register_button).setOnClickListener {
            registrationViewModel.createAccountAndLogin(
                    usernameEditText.text.toString(),
                    passwordEditText.text.toString()
            )
        }

        // RegistrationViewModel updates the registrationState to
        // REGISTRATION_COMPLETED when ready, and for this example, the username
        // is accessed as a read-only property from RegistrationViewModel and is
        // used to directly authenticate with loginViewModel.
        registrationViewModel.registrationState.observe(
                viewLifecycleOwner, Observer { state ->
                    if (state == REGISTRATION_COMPLETED) {

                        // Here we authenticate with the token provided by the ViewModel
                        // then pop back to the profie_fragment, where the user authentication
                        // status will be tested and should be authenticated.
                        val authToken = registrationViewModel.token
                        loginViewModel.authenticate(authToken)
                        navController.popBackStack(R.id.profile_fragment, false)
                    }
                }
        )

        // If the user presses back, cancel the user registration and pop back
        // to the login fragment. Since this ViewModel is shared at the activity
        // scope, its state must be reset so that it is in the initial state if
        // the user comes back to register later.
        requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
            registrationViewModel.userCancelledRegistration()
            navController.popBackStack(R.id.login_fragment, false)
        })

    }
}

자바

public class EnterProfileDataFragment extends Fragment {

    private RegistrationViewModel registrationViewModel;
    ...

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {

        registrationViewModel = new ViewModelProvider(requireActivity())
                .get(RegistrationViewModel.class);

        final NavController navController = findNavController(view);
        ...

        // When the next button is clicked, collect the current values from the two edit texts
        // and pass to the ViewModel to store.
        view.findViewById(R.id.next_button).setOnClickListener(v -> {
            String name = fullnameEditText.getText().toString();
            String bio = bioEditText.getText().toString();
            registrationViewModel.collectProfileData(name, bio);
        });

        // RegistrationViewModel updates the registrationState to
        // COLLECT_USER_PASSWORD when ready to move to the choose username and
        // password screen.
        registrationViewModel.getRegistrationState().observe(getViewLifecycleOwner(), state -> {
            if (state == COLLECT_USER_PASSWORD) {
                navController.navigate(R.id.move_to_choose_user_password);
            }
        });

        // If the user presses back, cancel the user registration and pop back
        // to the login fragment. Since this ViewModel is shared at the activity
        // scope, its state must be reset so that it is in the initial state if
        // the user comes back to register later.
        requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(),
            new OnBackPressedCallback(true) {
                @Override
                public void handleOnBackPressed() {
                    registrationViewModel.userCancelledRegistration();
                    navController.popBackStack(R.id.login_fragment, false);
                }
            });
    }
}

class ChooseUserPasswordFragment extends Fragment {

    private LoginViewModel loginViewModel;
    private RegistrationViewModel registrationViewModel;
    ...

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {

        ViewModelProvider provider = new ViewModelProvider(requireActivity());
        registrationViewModel = provider.get(RegistrationViewModel.class);
        loginViewModel = provider.get(LoginViewModel.class);

        final NavController navController = findNavController(view);

        ...

        // When the register button is clicked, collect the current values from
        // the two edit texts and pass to the ViewModel to complete registration.
        view.findViewById(R.id.register_button).setOnClickListener(v ->
                registrationViewModel.createAccountAndLogin(
                        usernameEditText.getText().toString(),
                        passwordEditText.getText().toString()
                )
        );

        // RegistrationViewModel updates the registrationState to
        // REGISTRATION_COMPLETED when ready, and for this example, the username
        // is accessed as a read-only property from RegistrationViewModel and is
        // used to directly authenticate with loginViewModel.
        registrationViewModel.getRegistrationState().observe(
                getViewLifecycleOwner(), state -> {

                    if (state == REGISTRATION_COMPLETED) {
                        // Here we authenticate with the token provided by the ViewModel
                        // then pop back to the profie_fragment, where the user authentication
                        // status will be tested and should be authenticated.
                        String authToken = registrationViewModel.getAuthToken();
                        loginViewModel.authenticate(authToken);
                        navController.popBackStack(R.id.profile_fragment, false);
                    }
                }
        );

        // If the user presses back, cancel the user registration and pop back
        // to the login fragment. Since this ViewModel is shared at the activity
        // scope, its state must be reset so that it will be in the initial
        // state if the user comes back to register later.
        requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(),
            new OnBackPressedCallback(true) {
                @Override
                public void handleOnBackPressed() {
                    registrationViewModel.userCancelledRegistration();
                    navController.popBackStack(R.id.login_fragment, false);
                }
            });
    }
}

이 FTUE 흐름을 자체 그래프로 분리된 상태로 유지하면 기본 탐색 흐름에 영향을 주지 않고 하위 흐름을 쉽게 변경할 수 있습니다. 중첩된 FTUE 그래프를 추가로 캡슐화하려면 별도의 탐색 리소스 파일에 이 그래프를 저장한 다음 <include> 요소를 통해 기본 탐색 그래프에 포함할 수도 있습니다.