Skip to content

Commit

Permalink
Feed Loading Strategy (#62)
Browse files Browse the repository at this point in the history
> This is an idea I have to load the feed lazily given the current
infrastructure.
> 
> When we request a feed, we have a list of ID's, so we can populate our
LazyList with a bunch of Loading State items.
> 
> Whenever I Loading State item comes into the Composition, it launches
a `SideEffect` which will call up to the ViewModel to fetch that item.
This seems like a decent approach without having to explicitly manage a
paging mechanism in the ViewModel.

After second thought, I think the better approach is paging. And since
we don't have actual paging via the API, we can simulate it.

Created a simple repository layer which wraps the actual base client,
and adds the ability to request pages.
The requests are still sequential, but that can be updated later to be a
series of async requests.

Some rationale here is that although with first approach we can keep
scrolling, a fling will cause a bunch of network requests which could
end up being quite costly.

The only thing that is a bit funky is the LazyList detecting how close
we are to the end logic. I think this solution works but it probably
needs more testing.
  • Loading branch information
Rahkeen authored Jul 10, 2024
1 parent 782bfd0 commit f1e12c8
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 78 deletions.
17 changes: 12 additions & 5 deletions android/app/src/main/java/com/emergetools/HackerNewsApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,26 @@ package com.emergetools

import android.app.Application
import android.content.Context
import com.emergetools.hackernews.data.HackerNewsBaseClient
import com.emergetools.hackernews.data.HackerNewsBaseDataSource
import com.emergetools.hackernews.data.HackerNewsSearchClient
import com.emergetools.hackernews.data.ItemRepository
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.time.Duration

class HackerNewsApplication: Application() {
private val json = Json { ignoreUnknownKeys = true }
private val httpClient = OkHttpClient.Builder()
.readTimeout(Duration.ofSeconds(30))
.build()

val baseClient = HackerNewsBaseClient(json)
val searchClient = HackerNewsSearchClient(json)
private val baseClient = HackerNewsBaseDataSource(json, httpClient)
val searchClient = HackerNewsSearchClient(json, httpClient)
val itemRepository = ItemRepository(baseClient)
}

fun Context.baseClient(): HackerNewsBaseClient {
return (this.applicationContext as HackerNewsApplication).baseClient
fun Context.itemRepository(): ItemRepository {
return (this.applicationContext as HackerNewsApplication).itemRepository
}

fun Context.searchClient(): HackerNewsSearchClient {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.emergetools.hackernews.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.GET
Expand Down Expand Up @@ -34,10 +35,11 @@ interface HackerNewsBaseApi {
suspend fun getItem(@Path("id") itemId: Long): Item
}

class HackerNewsBaseClient(json: Json) {
class HackerNewsBaseDataSource(json: Json, client: OkHttpClient) {
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_FIREBASE_URL)
.addConverterFactory(json.asConverterFactory("application/json; charset=UTF8".toMediaType()))
.client(client)
.build()

val api = retrofit.create(HackerNewsBaseApi::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.emergetools.hackernews.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.GET
Expand All @@ -26,10 +27,11 @@ interface HackerNewsAlgoliaApi {
suspend fun getItem(@Path("id") itemId: Long): ItemResponse
}

class HackerNewsSearchClient(json: Json) {
class HackerNewsSearchClient(json: Json, client: OkHttpClient) {
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_SEARCH_URL)
.addConverterFactory(json.asConverterFactory("application/json; charset=UTF8".toMediaType()))
.client(client)
.build()

val api: HackerNewsAlgoliaApi = retrofit.create(HackerNewsAlgoliaApi::class.java)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.emergetools.hackernews.data

import com.emergetools.hackernews.features.stories.FeedType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

typealias ItemId = Long
typealias Page = List<ItemId>

class ItemRepository(
private val baseClient: HackerNewsBaseDataSource,
) {
suspend fun getFeedIds(type: FeedType): Page {
return withContext(Dispatchers.IO) {
when (type) {
FeedType.Top -> {
baseClient.api.getTopStoryIds()
}
FeedType.New -> {
baseClient.api.getNewStoryIds()
}
}
}
}

suspend fun getItem(id: ItemId): Item {
return withContext(Dispatchers.IO) {
baseClient.api.getItem(id)
}
}

suspend fun getPage(page: Page): List<Item> {
return withContext(Dispatchers.IO) {
val result = mutableListOf<Item>()
page.forEach { itemId ->
val item = baseClient.api.getItem(itemId)
result.add(item)
}
result.toList()
}
}
}

fun MutableList<Page>.next() = removeFirst()
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
package com.emergetools.hackernews.features.stories

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.emergetools.hackernews.data.HackerNewsBaseClient
import com.emergetools.hackernews.data.Item
import com.emergetools.hackernews.data.ItemRepository
import com.emergetools.hackernews.data.Page
import com.emergetools.hackernews.data.next
import com.emergetools.hackernews.features.comments.CommentsDestinations
import com.emergetools.hackernews.features.stories.StoriesAction.LoadStories
import kotlinx.coroutines.Dispatchers
import com.emergetools.hackernews.features.stories.StoriesAction.LoadItems
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

enum class FeedType(val label: String) {
Top("Top"),
New("New")
}

data class StoriesState(
val stories: List<StoryItem>,
val feed: FeedType = FeedType.Top

val feed: FeedType = FeedType.Top,
val loading: Boolean = true
)

sealed class StoryItem(open val id: Long) {
Expand All @@ -36,67 +39,69 @@ sealed class StoryItem(open val id: Long) {
}

sealed class StoriesAction {
data object LoadStories : StoriesAction()
data object LoadItems : StoriesAction()
data object LoadNextPage : StoriesAction()
data class SelectStory(val id: Long) : StoriesAction()
data class SelectComments(val id: Long) : StoriesAction()
data class SelectFeed(val feed: FeedType) : StoriesAction()
}

// TODO(rikin): Second pass at Navigation Setup
sealed interface StoriesNavigation {
data class GoToStory(val closeup: StoriesDestinations.Closeup) : StoriesNavigation
data class GoToComments(val comments: CommentsDestinations.Comments) : StoriesNavigation
}

class StoriesViewModel(private val baseClient: HackerNewsBaseClient) : ViewModel() {
class StoriesViewModel(private val itemRepository: ItemRepository) : ViewModel() {
private val internalState = MutableStateFlow(StoriesState(stories = emptyList()))
val state = internalState.asStateFlow()

// TODO: decide if this should be in the ViewModel or the Repository
private val pages = mutableListOf<Page>()

init {
actions(LoadStories)
actions(LoadItems)
}

fun actions(action: StoriesAction) {
when (action) {
LoadStories -> {
LoadItems -> {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val ids = when(internalState.value.feed) {
FeedType.Top -> {
baseClient.api.getTopStoryIds()
}
FeedType.New -> {
baseClient.api.getNewStoryIds()
}
}
pages.addAll(
itemRepository
.getFeedIds(internalState.value.feed)
.chunked(FEED_PAGE_SIZE)
)
val page = pages.next()
Log.d("Feed", "Loading first page: $page")
internalState.update { current ->
current.copy(
stories = page.map { StoryItem.Loading(it) },
loading = true
)
}

// now for each ID I need to load the item.
internalState.update { current ->
current.copy(
stories = ids.map { StoryItem.Loading(it) }
var newStories = itemRepository
.getPage(page)
.map<Item, StoryItem> { item ->
StoryItem.Content(
id = item.id,
title = item.title!!,
author = item.by!!,
score = item.score ?: 0,
commentCount = item.descendants ?: 0,
url = item.url
)
}
ids.forEach { id ->
val item = baseClient.api.getItem(id)
internalState.update { current ->
current.copy(
stories = current.stories.map {
if (it.id == item.id) {
StoryItem.Content(
id = item.id,
title = item.title!!,
author = item.by!!,
score = item.score ?: 0,
commentCount = item.descendants ?: 0,
url = item.url
)
} else {
it
}
}
)
}
}

if (pages.isNotEmpty()) {
newStories = newStories + StoryItem.Loading(0L)
}

internalState.update { current ->
current.copy(
stories = newStories,
loading = false
)
}
}
}
Expand All @@ -116,15 +121,54 @@ class StoriesViewModel(private val baseClient: HackerNewsBaseClient) : ViewModel
stories = emptyList()
)
}
actions(LoadStories)
actions(LoadItems)
}

StoriesAction.LoadNextPage -> {
if (pages.isNotEmpty() && !state.value.loading) {
viewModelScope.launch {
val page = pages.next()
Log.d("Feed", "Loading next page: $page")
internalState.update { current ->
current.copy(loading = true)
}

var storiesToAdd = itemRepository
.getPage(page)
.map<Item, StoryItem> { item ->
StoryItem.Content(
id = item.id,
title = item.title!!,
author = item.by!!,
score = item.score ?: 0,
commentCount = item.descendants ?: 0,
url = item.url
)
}

if (pages.isNotEmpty()) {
storiesToAdd = storiesToAdd + StoryItem.Loading(0L)
}

internalState.update { current ->
val newStories = current.stories.subList(0, current.stories.lastIndex) + storiesToAdd
current.copy(
stories = newStories,
loading = false
)
}
}
}
}
}
}

@Suppress("UNCHECKED_CAST")
class Factory(private val baseClient: HackerNewsBaseClient) : ViewModelProvider.Factory {
class Factory(private val itemRepository: ItemRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return StoriesViewModel(baseClient) as T
return StoriesViewModel(itemRepository) as T
}
}
}

const val FEED_PAGE_SIZE = 20
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import androidx.navigation.toRoute
import com.emergetools.baseClient
import com.emergetools.hackernews.features.stories.StoriesDestinations.Closeup
import com.emergetools.hackernews.features.stories.StoriesDestinations.Feed
import com.emergetools.itemRepository
import kotlinx.serialization.Serializable

@Serializable
Expand All @@ -38,7 +38,7 @@ fun NavGraphBuilder.storiesGraph(navController: NavController) {

val model = viewModel<StoriesViewModel>(
factory = StoriesViewModel.Factory(
baseClient = context.baseClient()
itemRepository = context.itemRepository()
)
)
val state by model.state.collectAsState()
Expand All @@ -51,16 +51,16 @@ fun NavGraphBuilder.storiesGraph(navController: NavController) {
is StoriesNavigation.GoToComments -> {
navController.navigate(place.comments)
}

is StoriesNavigation.GoToStory -> {
// navController.navigate(place.closeup)
customTabsIntent.launchUrl(context, Uri.parse(place.closeup.url))
}
}
}
)
}
composable<Closeup> { entry ->
val closeup: Closeup = entry.toRoute()
val closeup: Closeup = entry.toRoute()
StoryScreen(closeup.url)
}
}
Expand Down
Loading

0 comments on commit f1e12c8

Please sign in to comment.