Android Dev/NowInAndroid / / 2022. 10. 28. 01:10

NowInAndroid(2) - ForYouViewModel

 

이전글 :  https://witcheryoon.tistory.com/336

 

ViewModel을 컨트롤하는 Flow method들을 이해하면 만드는데 도움이 될 것이다.

 

설명페이지1 :Flow-kotlinx-coroutine-core : https://witcheryoon.tistory.com/292: 리스트 : SharedFlow / shareIn / onSubscription / conflate / stateFlow / toStates / scan / reduce / merge

 

0> Full code : 

@OptIn(SavedStateHandleSaveableApi::class)
@HiltViewModel
class ForYouViewModel @Inject constructor(
    syncStatusMonitor: SyncStatusMonitor,
    private val userDataRepository: UserDataRepository,
    private val getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase,
    getSortedFollowableAuthorsStream: GetSortedFollowableAuthorsStreamUseCase,
    getFollowableTopicsStream: GetFollowableTopicsStreamUseCase,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val followedInterestsUiState: StateFlow<FollowedInterestsUiState> =
        userDataRepository.userDataStream
            .map { userData ->
                if (userData.followedAuthors.isEmpty() && userData.followedTopics.isEmpty()) {
                    None
                } else {
                    FollowedInterests(
                        authorIds = userData.followedAuthors,
                        topicIds = userData.followedTopics
                    )
                }
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = Unknown
            )

    /**
     * The in-progress set of topics to be selected, persisted through process death with a
     * [SavedStateHandle].
     */
    private var inProgressTopicSelection by savedStateHandle.saveable {
        mutableStateOf<Set<String>>(emptySet())
    }

    /**
     * The in-progress set of authors to be selected, persisted through process death with a
     * [SavedStateHandle].
     */
    private var inProgressAuthorSelection by savedStateHandle.saveable {
        mutableStateOf<Set<String>>(emptySet())
    }

    val isSyncing = syncStatusMonitor.isSyncing
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = false
        )

    val feedState: StateFlow<NewsFeedUiState> =
        combine(
            followedInterestsUiState,
            snapshotFlow { inProgressTopicSelection },
            snapshotFlow { inProgressAuthorSelection }
        ) { followedInterestsUiState, inProgressTopicSelection, inProgressAuthorSelection ->
            when (followedInterestsUiState) {
                // If we don't know the current selection state, emit loading.
                Unknown -> flowOf<NewsFeedUiState>(NewsFeedUiState.Loading)
                // If the user has followed topics, use those followed topics to populate the feed
                is FollowedInterests -> {
                    getSaveableNewsResourcesStream(
                        filterTopicIds = followedInterestsUiState.topicIds,
                        filterAuthorIds = followedInterestsUiState.authorIds
                    ).mapToFeedState()
                }
                // If the user hasn't followed interests yet, show a realtime populated feed based
                // on the in-progress interests selections, if there are any.
                None -> {
                    if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) {
                        flowOf<NewsFeedUiState>(NewsFeedUiState.Success(emptyList()))
                    } else {
                        getSaveableNewsResourcesStream(
                            filterTopicIds = inProgressTopicSelection,
                            filterAuthorIds = inProgressAuthorSelection
                        ).mapToFeedState()
                    }
                }
            }
        }
            // Flatten the feed flows.
            // As the selected topics and topic state changes, this will cancel the old feed
            // monitoring and start the new one.
            .flatMapLatest { it }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = NewsFeedUiState.Loading
            )

    val interestsSelectionUiState: StateFlow<ForYouInterestsSelectionUiState> =
        combine(
            followedInterestsUiState,
            getFollowableTopicsStream(
                followedTopicIdsStream = snapshotFlow { inProgressTopicSelection }
            ),
            snapshotFlow { inProgressAuthorSelection }.flatMapLatest {
                getSortedFollowableAuthorsStream(it)
            }
        ) { followedInterestsUiState, topics, authors ->
            when (followedInterestsUiState) {
                Unknown -> ForYouInterestsSelectionUiState.Loading
                is FollowedInterests -> ForYouInterestsSelectionUiState.NoInterestsSelection
                None -> {
                    if (topics.isEmpty() && authors.isEmpty()) {
                        ForYouInterestsSelectionUiState.LoadFailed
                    } else {
                        ForYouInterestsSelectionUiState.WithInterestsSelection(
                            topics = topics,
                            authors = authors
                        )
                    }
                }
            }
        }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = ForYouInterestsSelectionUiState.Loading
            )

    fun updateTopicSelection(topicId: String, isChecked: Boolean) {
        withMutableSnapshot {
            inProgressTopicSelection =
                // Update the in-progress selection based on whether the topic id was checked
                if (isChecked) {
                    inProgressTopicSelection + topicId
                } else {
                    inProgressTopicSelection - topicId
                }
        }
    }

    fun updateAuthorSelection(authorId: String, isChecked: Boolean) {
        withMutableSnapshot {
            inProgressAuthorSelection =
                // Update the in-progress selection based on whether the author id was checked
                if (isChecked) {
                    inProgressAuthorSelection + authorId
                } else {
                    inProgressAuthorSelection - authorId
                }
        }
    }

    fun updateNewsResourceSaved(newsResourceId: String, isChecked: Boolean) {
        viewModelScope.launch {
            userDataRepository.updateNewsResourceBookmark(newsResourceId, isChecked)
        }
    }

    fun saveFollowedInterests() {
        // Don't attempt to save anything if nothing is selected
        if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) {
            return
        }

        viewModelScope.launch {
            userDataRepository.setFollowedTopicIds(inProgressTopicSelection)
            userDataRepository.setFollowedAuthorIds(inProgressAuthorSelection)
            // Clear out the old selection, in case we return to onboarding
            withMutableSnapshot {
                inProgressTopicSelection = emptySet()
                inProgressAuthorSelection = emptySet()
            }
        }
    }
}

private fun Flow<List<SaveableNewsResource>>.mapToFeedState(): Flow<NewsFeedUiState> =
    map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
        .onStart { emit(NewsFeedUiState.Loading) }

 

 

1> followedInterestUIState

Flow-kotlinx-coroutine-core의 StateFlow type의 UiState Hot Follow Emitter이다.

    private val followedInterestsUiState: StateFlow<FollowedInterestsUiState> =
        userDataRepository.userDataStream
            .map { userData ->
                if (userData.followedAuthors.isEmpty() && userData.followedTopics.isEmpty()) {
                    None
                } else {
                    FollowedInterests(
                        authorIds = userData.followedAuthors,
                        topicIds = userData.followedTopics
                    )
                }
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = Unknown
            )

1) DI 로 generate 된 userDataRepository의 userDataStream method를 호출해 userData를 map으로 불러온 뒤 FollwedIntersts data class로 감싸서 FollowedInterestsUiState 타입으로 변형한다.

2) 이를 stateIn으로 viewmodelScope에서 동작하고 stopTimeout 5초,  initialValue는 Unkown : FollowedInterestsUiState 상태를 가지는 hotFlow로 type change한다.

 

2> savedStateHandle, isSyncing 

    /**
     * The in-progress set of topics to be selected, persisted through process death with a
     * [SavedStateHandle].
     */
    private var inProgressTopicSelection: Set<String> by savedStateHandle.saveable {
        mutableStateOf<Set<String>>(emptySet())
    }

    /**
     * The in-progress set of authors to be selected, persisted through process death with a
     * [SavedStateHandle].
     */
    private var inProgressAuthorSelection: Set<String> by savedStateHandle.saveable {
        mutableStateOf<Set<String>>(emptySet())
    }

    val isSyncing = syncStatusMonitor.isSyncing
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = false
        )

1) savedStateHandle로부터 Selection 상태에 대한 data 를 saveable을 통해서 불러와서 이전 state를 복구하는 변수를 만든다. Topic과 Author인  2부분을 가져왔다.
2) isSyncing는 isSycning을 1>의 followedInterestUIState와 같다. hotflow StateFlow<Boolean> 타입으로 선언한 모습이다.

 

3> feedState : NewsFeedUiState의 StateFlow

 val feedState: StateFlow<NewsFeedUiState> =
     combine(
         followedInterestsUiState,
         snapshotFlow { inProgressTopicSelection },
         snapshotFlow { inProgressAuthorSelection }
     ) { followedInterestsUiState, inProgressTopicSelection, inProgressAuthorSelection ->
         when (followedInterestsUiState) {
             // If we don't know the current selection state, emit loading.
             Unknown -> flowOf<NewsFeedUiState>(NewsFeedUiState.Loading)
             // If the user has followed topics, use those followed topics to populate the feed
             is FollowedInterests -> {
                 getSaveableNewsResourcesStream(
                     filterTopicIds = followedInterestsUiState.topicIds,
                     filterAuthorIds = followedInterestsUiState.authorIds
                 ).mapToFeedState()
             }
             // If the user hasn't followed interests yet, show a realtime populated feed based
             // on the in-progress interests selections, if there are any.
             None -> {
                 if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) {
                     flowOf<NewsFeedUiState>(NewsFeedUiState.Success(emptyList()))
                 } else {
                     getSaveableNewsResourcesStream(
                         filterTopicIds = inProgressTopicSelection,
                         filterAuthorIds = inProgressAuthorSelection
                     ).mapToFeedState()
                 }
             }
         }
     }
         // Flatten the feed flows.
         // As the selected topics and topic state changes, this will cancel the old feed
         // monitoring and start the new one.
         .flatMapLatest { it }
         .stateIn(
             scope = viewModelScope,
             started = SharingStarted.WhileSubscribed(5_000),
             initialValue = NewsFeedUiState.Loading
         )

 

FollowedInterestsUiState는 아래의 3가지 State로 정의됨.
1> Unknown, 2> None, 그리고 data class instance인 3> FollwedInterests 

 

stateFlow를 사용하여 UiState를 제어

 

FollowedInterestsUiState를 Transforming하는 mapToFeed의 구성은 아래와 같다

mapToFeesState 사용 예

 

 

DI된 GetSaveableNewsResourcesStreamUseCase

 

위는 GetSaveableNewsResourcesStreamUseCase.kt에서 invoke가 구현되어 있고 아래와 같다.

( invoke 함수를 통해 filterTopicIds: Set<String> filterTopicIds, filterAuthorIds: Set<String> 를 인자로 받는 customize된 apply method의 구현이 가능하여 mapToFeesState 사용 예와 같은 실행이 가능함 )

 

invoke호출시 repository에서 stream을 호출하여 List<SaveableNewsResource> 를 flow을 리턴한다는 것이고 mapToFeedState

 

class GetSaveableNewsResourcesStreamUseCase @Inject constructor(
    private val newsRepository: NewsRepository,
    userDataRepository: UserDataRepository
) {

    private val bookmarkedNewsResourcesStream = userDataRepository.userDataStream.map {
        it.bookmarkedNewsResources
    }

    /**
     * Returns a list of SaveableNewsResources which match the supplied set of topic ids or author
     * ids.
     *
     * @param filterTopicIds - A set of topic ids used to filter the list of news resources. If
     * this is empty AND filterAuthorIds is empty the list of news resources will not be filtered.
     * @param filterAuthorIds - A set of author ids used to filter the list of news resources. If
     * this is empty AND filterTopicIds is empty the list of news resources will not be filtered.
     *
     */
    operator fun invoke(
        filterTopicIds: Set<String> = emptySet(),
        filterAuthorIds: Set<String> = emptySet()
    ): Flow<List<SaveableNewsResource>> =
        if (filterTopicIds.isEmpty() && filterAuthorIds.isEmpty()) {
            newsRepository.getNewsResourcesStream()
        } else {
            newsRepository.getNewsResourcesStream(
                filterTopicIds = filterTopicIds,
                filterAuthorIds = filterAuthorIds
            )
        }.mapToSaveableNewsResources(bookmarkedNewsResourcesStream)
}

private fun Flow<List<NewsResource>>.mapToSaveableNewsResources(
    savedNewsResourceIdsStream: Flow<Set<String>>
): Flow<List<SaveableNewsResource>> =
    filterNot { it.isEmpty() }
        .combine(savedNewsResourceIdsStream) { newsResources, savedNewsResourceIds ->
            newsResources.map { newsResource ->
                SaveableNewsResource(
                    newsResource = newsResource,
                    isSaved = savedNewsResourceIds.contains(newsResource.id)
                )
            }
        }

 

 

private fun Flow<List<SaveableNewsResource>>.mapToFeedState(): Flow<NewsFeedUiState> =
    map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
        .onStart { emit(NewsFeedUiState.Loading) }

(onStart 참조 : https://witcheryoon.tistory.com/292)

 

 

UIState에 대한 sealed state는 아래와 같음.

 

FollowedInterestsUiState

/**
 * A sealed hierarchy for the user's current followed interests state.
 */
sealed interface FollowedInterestsUiState {

    /**
     * The current state is unknown (hasn't loaded yet)
     */
    object Unknown : FollowedInterestsUiState

    /**
     * The user hasn't followed any interests yet.
     */
    object None : FollowedInterestsUiState

    /**
     * The user has followed the given (non-empty) set of [topicIds] or [authorIds].
     */
    data class FollowedInterests(
        val topicIds: Set<String>,
        val authorIds: Set<String>
    ) : FollowedInterestsUiState
}

 

NewsFeedUiState

/**
 * A sealed hierarchy describing the state of the feed of news resources.
 */
sealed interface NewsFeedUiState {
    /**
     * The feed is still loading.
     */
    object Loading : NewsFeedUiState

    /**
     * The feed is loaded with the given list of news resources.
     */
    data class Success(
        /**
         * The list of news resources contained in this feed.
         */
        val feed: List<SaveableNewsResource>
    ) : NewsFeedUiState
}

 

위 UiState는 NewFeeds.kt에서 아래와 같이 UI에 따른 state변화로 view을 control할때 사용됨. 추후 이 부분을 중점적으로 리뷰 해야겠음

 

 

 

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유