Skip to content

Commit 7698b79

Browse files
SoxiaLiSAclaude
andcommitted
feat(feeds): 翻页节流 1s + 连翻 30 页预算,超了 footer 改手动继续
线上遥测:一页只剩一两条可展示(其余被 R-18 / 屏蔽本地过滤掉)时,那一两条 一绑定就落在预取区、立刻再翻——一次搜索 48 秒翻 89 页、一直翻到 pixiv 的 5000 条 offset 上限,没有一个是人在看。 - FeedSource.pagingPolicy() → FeedPagingPolicy(默认全框架生效): 相邻两次 loadMore 网络页至少隔 1 s(含空页追载的每一跳); 连着翻满 30 页置 appendPaused,footer 变「点击加载更多」,点一下再给一份预算。 - 预算只数「连着翻」的页:两页之间隔 ≥ 5 s 归零,正常人翻多深都不会被拦。 - 刷新整条链路和刷新后的第一次翻页不受限(首屏是用户动作,追载有跳数硬上限)。 - adoptCursor 清 appendPaused,与 reachedEnd 保持互斥。 - 文案 feed_append_paused_tap_to_continue 7 个 locale;docs/feeds-module.md 第 4 节。 :feeds 单测 60/60(新增 12),:app 编译与 feeds 相关单测通过。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vr4LZN5Jpgt4kpqT9pukLd
1 parent 528012b commit 7698b79

15 files changed

Lines changed: 510 additions & 13 deletions

File tree

docs/feeds-module.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ ShaftFeedHost.install()
5656
- **AGP 8 默认 non-transitive R**`:app` 引用搬走的资源(`@layout/fragment_feed`
5757
`@color/feed_skeleton_block` 等)必须写成 `ceui.pixiv.feeds.R.xxx`,光靠资源合并
5858
只在 XML 里(`@layout/fragment_feed`)自动生效,Kotlin/Java 侧不行。
59-
- **三条文案随框架搬进了模块**`empty_list_1` / `list_load_failed_tap_retry` /
60-
`feed_error_tap_retry`,7 个 locale 全带)。`scripts/sort_locale_strings.py`
59+
- **文案随框架住在模块里**`empty_list_1` / `list_load_failed_tap_retry` /
60+
`feed_error_tap_retry` / `feed_append_paused_tap_to_continue`,7 个 locale 全带)。`scripts/sort_locale_strings.py`
6161
`scripts/find_missing_used_strings.py` 的默认路径写死在 `app/src/main/res` 下,
6262
跑这几条要把路径显式指到 `feeds/src/main`,两个脚本参数名不同:
6363
`find_missing_used_strings.py``--source-root` / `--source-values` / `--target`
@@ -66,3 +66,30 @@ ShaftFeedHost.install()
6666
`checkDependencies` 默认 false)。改了 `:feeds` 要单独跑 `./gradlew :feeds:lintDebug`
6767
没给 `:app` 打开 `checkDependencies`——那会把 `:models` / `:progressmanager` /
6868
`:flowlayout-lib` 这些老模块的存量问题一起灌进本就是红的 app lint 里。
69+
70+
---
71+
72+
## 4. 翻页的节奏与预算(`FeedPagingPolicy`
73+
74+
触底预取是零间隔的:`FeedAdapter.onBind` 进尾部 6 条就调 `loadMore()`,页一提交
75+
`FeedFragment.rearmPaginationIfNearEnd` 再补一次。一页只剩一两条可展示(其余被 R-18 /
76+
屏蔽等本地过滤掉,过滤发生在 `FeedSource.load` 的 mapper 里,框架只看到「薄页」)时,
77+
那一两条一绑定就又落在预取区,于是一页接一页零间隔连翻——线上遥测里一次搜索 48 秒翻
78+
89 页、一直翻到 pixiv 的 5000 条 offset 上限,全是这个形态,没有一个是人在看。
79+
80+
`FeedViewModel` 因此对**首屏之后的每一次网络翻页**(含空页追载的每一跳)过两道闸,
81+
参数来自 `FeedSource.pagingPolicy()`(默认 `FeedPagingPolicy.Default`,全框架统一):
82+
83+
- **最小间隔** `minPageIntervalMs`(默认 1 s):相邻两次 `loadMore` 网络页至少隔这么久,
84+
以上一页返回时刻起算。等待发生在 append 已置 Loading 之后(footer 转圈),预取信号不会
85+
丢;下拉刷新照常取消它。刷新整条链路(首屏 + 空页追载)和刷新后的第一次翻页都不受限:
86+
那是用户的动作,追载又有跳数硬上限跑不飞,节流它只会让重过滤用户(#729)首屏多白等。
87+
- **连翻预算** `maxAutoPages`(默认 30 页):跑飞和人的区别不在翻了多少页,而在页与页之间
88+
有没有停下来看(线上人类重度翻页 12–20 s/页,跑飞 ≤ 2.4 s/页)。两页之间隔了
89+
`burstIdleResetMs`(默认 5 s)以上预算就归零,所以自己账号的收藏、关注动态翻多深都碰不到;
90+
连着翻满就置 `FeedUiState.appendPaused`,之后滚动触发一律忽略,footer 变成
91+
「点击加载更多」,用户点一下 `continueAppend()` 再给一份预算。预算判定排在空页追载
92+
`MAX_EMPTY_PAGE_HOPS`)之前——薄页和整页滤空烧的是同一份预算。
93+
94+
没有网络代价的数据源(本地库)可覆写 `pagingPolicy()` 返回 `FeedPagingPolicy.Unlimited`
95+
`refreshAfterCacheHit()` 同一契约:每次现读、纯内存、不抛。

feeds/src/main/java/ceui/pixiv/feeds/AppendFooter.kt

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,23 @@ import androidx.core.view.isVisible
44
import ceui.pixiv.feeds.databinding.ItemFeedAppendFooterBinding
55

66
/**
7-
* 框架内部的翻页 footer:追加中转菊花,追加失败变「点击重试」。
8-
* 由 [FeedFragment] 根据 [FeedUiState.append] 拼进展示列表,业务不感知。
7+
* 框架内部的翻页 footer:追加中转菊花,追加失败变「点击重试」,自动翻页预算用完变
8+
* 「点击加载更多」。由 [FeedFragment] 根据 [FeedUiState.append] / [FeedUiState.appendPaused]
9+
* 拼进展示列表,业务不感知。
910
*/
10-
internal data class AppendFooterItem(val state: LoadState) : FeedItem {
11+
internal data class AppendFooterItem(
12+
val state: LoadState,
13+
/** [FeedUiState.appendPaused]:预算用完等用户点「继续」。与 Loading / Error 互斥。 */
14+
val paused: Boolean = false,
15+
) : FeedItem {
1116

1217
override val feedKey: Any
1318
get() = "feeds:append_footer"
1419
}
1520

1621
internal class AppendFooterRenderer(
1722
private val onRetry: () -> Unit,
23+
private val onContinue: () -> Unit,
1824
) : FeedRenderer<AppendFooterItem, ItemFeedAppendFooterBinding>(
1925
AppendFooterItem::class.java,
2026
ItemFeedAppendFooterBinding::inflate,
@@ -25,15 +31,19 @@ internal class AppendFooterRenderer(
2531

2632
override fun onCreate(cell: FeedCell<AppendFooterItem, ItemFeedAppendFooterBinding>) {
2733
cell.binding.root.setOnClickListener {
28-
if (cell.itemOrNull?.state is LoadState.Error) {
29-
onRetry()
34+
val item = cell.itemOrNull ?: return@setOnClickListener
35+
when {
36+
item.paused -> onContinue()
37+
item.state is LoadState.Error -> onRetry()
3038
}
3139
}
3240
}
3341

3442
override fun onBind(cell: FeedCell<AppendFooterItem, ItemFeedAppendFooterBinding>) {
3543
val isError = cell.item.state is LoadState.Error
36-
cell.binding.feedFooterProgress.isVisible = !isError
44+
val isPaused = cell.item.paused
45+
cell.binding.feedFooterProgress.isVisible = !isError && !isPaused
3746
cell.binding.feedFooterError.isVisible = isError
47+
cell.binding.feedFooterPaused.isVisible = isPaused
3848
}
3949
}

feeds/src/main/java/ceui/pixiv/feeds/FeedFragment.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,10 @@ abstract class FeedFragment(
195195
binding.feedSkeleton.removeAllViews()
196196

197197
val adapter = FeedAdapter(
198-
renderers = onCreateRenderers() + AppendFooterRenderer { feedViewModel.retryAppend() },
198+
renderers = onCreateRenderers() + AppendFooterRenderer(
199+
onRetry = { feedViewModel.retryAppend() },
200+
onContinue = { feedViewModel.continueAppend() },
201+
),
199202
onNearEnd = { if (loadMoreEnabled) feedViewModel.loadMore() },
200203
)
201204
feedAdapter = adapter
@@ -488,8 +491,11 @@ abstract class FeedFragment(
488491
/** adapter 现取不缓存:[rebuildList] 换过 adapter 之后,长活的 uiState collector 不能再往旧的提交。 */
489492
private fun render(state: FeedUiState) {
490493
val adapter = feedAdapter ?: return
491-
val displayList = when (val append = state.append) {
492-
is LoadState.Loading, is LoadState.Error -> state.items + AppendFooterItem(append)
494+
val displayList = when {
495+
state.append is LoadState.Loading || state.append is LoadState.Error ->
496+
state.items + AppendFooterItem(state.append)
497+
// 自动翻页预算用完:还有下一页,footer 变「点击加载更多」等用户点
498+
state.appendPaused -> state.items + AppendFooterItem(state.append, paused = true)
493499
else -> state.items
494500
}
495501
// 整代替换(下拉刷新 / 冷启的缓存→网络)**且新旧两代真的会撕**时才绕开 DiffUtil,

feeds/src/main/java/ceui/pixiv/feeds/FeedSource.kt

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,53 @@ data class FeedPage<Cursor : Any>(
1010
val nextCursor: Cursor?,
1111
)
1212

13+
/**
14+
* 自动翻页的节奏与预算。
15+
*
16+
* 触底预取是零间隔的:一页只剩一两条可展示(其余被 R-18 / 屏蔽等本地过滤掉)时,那一两条
17+
* 一绑定就落在预取区、立刻再翻一页——线上遥测里一次搜索 48 秒翻 89 页、一直翻到 pixiv 的
18+
* 5000 条 offset 上限,全是这个形态,没有一个是人在看。两条闸门把它压回人的节奏:
19+
*
20+
* - [minPageIntervalMs]:相邻两次 [FeedViewModel.loadMore] 网络翻页之间的最小间隔(含空页
21+
* 追载的每一跳),以上一页返回时刻起算。等待期间 footer 照常转圈,预取信号不会丢。
22+
* 首屏 / 下拉刷新整条链路不受限,刷新后的第一次翻页也不受限——那是用户的动作,
23+
* 而且刷新自带的空页追载有 [FeedViewModel] 的跳数硬上限,跑不飞,节流它只会让重过滤
24+
* 用户的首屏多白等。
25+
* - [maxAutoPages]:**连着翻**的页数预算。跑飞和人的区别不在翻了多少页,而在页与页之间有
26+
* 没有停下来看:线上人类重度翻页 12–20 s/页,跑飞 ≤ 2.4 s/页。所以两页之间隔了
27+
* [burstIdleResetMs] 以上就算「看过了」,预算归零——自己账号的收藏、关注动态翻多深都碰
28+
* 不到它;薄页跑飞(节流后 1 s 一页)30 页后停下,footer 变成「点击加载更多」,用户点一下
29+
* 再给一份预算([FeedViewModel.continueAppend])。
30+
*
31+
* 默认值 [Default] 全框架生效。个别数据源(本地库、无网络代价的列表)可覆写
32+
* [FeedSource.pagingPolicy] 放宽到 [Unlimited]。
33+
*/
34+
data class FeedPagingPolicy(
35+
val maxAutoPages: Int = DEFAULT_MAX_AUTO_PAGES,
36+
val minPageIntervalMs: Long = DEFAULT_MIN_PAGE_INTERVAL_MS,
37+
val burstIdleResetMs: Long = DEFAULT_BURST_IDLE_RESET_MS,
38+
) {
39+
init {
40+
require(maxAutoPages >= 1) { "maxAutoPages 至少为 1(无上限用 Unlimited):$maxAutoPages" }
41+
require(minPageIntervalMs >= 0) { "minPageIntervalMs 不能为负:$minPageIntervalMs" }
42+
require(burstIdleResetMs >= 0) { "burstIdleResetMs 不能为负:$burstIdleResetMs" }
43+
}
44+
45+
companion object {
46+
const val DEFAULT_MAX_AUTO_PAGES: Int = 30
47+
const val DEFAULT_MIN_PAGE_INTERVAL_MS: Long = 1_000L
48+
const val DEFAULT_BURST_IDLE_RESET_MS: Long = 5_000L
49+
50+
val Default: FeedPagingPolicy = FeedPagingPolicy()
51+
52+
/** 不节流、不设预算:只给没有网络代价的数据源用。 */
53+
val Unlimited: FeedPagingPolicy = FeedPagingPolicy(
54+
maxAutoPages = Int.MAX_VALUE,
55+
minPageIntervalMs = 0L,
56+
)
57+
}
58+
}
59+
1360
/**
1461
* 数据源契约:一个必做的 suspend [load],外加一个可选的本地优先能力 [loadFromCache]。
1562
*
@@ -56,4 +103,12 @@ fun interface FeedSource<Cursor : Any> {
56103
* 抛出来会经 viewModelScope 直奔线程默认处理器崩掉进程;要读磁盘请改用 [loadFromCache]。
57104
*/
58105
fun refreshAfterCacheHit(): Boolean = true
106+
107+
/**
108+
* 自动翻页的节奏与预算,默认 [FeedPagingPolicy.Default](全框架统一的闸门)。
109+
*
110+
* 每次 [FeedViewModel.refresh] / [FeedViewModel.loadMore] 现读。与 [refreshAfterCacheHit]
111+
* 同一契约:必须是廉价的纯内存判断,**不要碰 IO、不要抛**。
112+
*/
113+
fun pagingPolicy(): FeedPagingPolicy = FeedPagingPolicy.Default
59114
}

feeds/src/main/java/ceui/pixiv/feeds/FeedUiState.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ data class FeedUiState(
1616
val append: LoadState = LoadState.Idle,
1717
/** 是否已翻到底(没有下一页游标)。 */
1818
val reachedEnd: Boolean = false,
19+
/**
20+
* 自动翻页预算([FeedPagingPolicy.maxAutoPages])已用完:还有下一页,但不再由滚动触发,
21+
* footer 变成「点击加载更多」,等用户点 [FeedViewModel.continueAppend] 再给一份预算。
22+
* 与 [reachedEnd] 互斥(到底了就没有「继续」可言):refresh 整代提交和游标交接
23+
* ([FeedViewModel.adoptCursor])都把它清掉。
24+
*/
25+
val appendPaused: Boolean = false,
1926
/** 是否成功完成过至少一次刷新,用于区分「首屏加载」和「后续刷新」。 */
2027
val hasLoadedOnce: Boolean = false,
2128
/**

0 commit comments

Comments
 (0)