更加智能的阅读记录逻辑

This commit is contained in:
HapeLee
2025-11-26 02:34:20 +08:00
parent 3dfc1f5c25
commit 16b89c43f3
8 changed files with 322 additions and 137 deletions
@@ -92,6 +92,19 @@ interface ReadRecordDao {
@Query("DELETE FROM readRecordDetail WHERE bookName = :bookName")
fun deleteDetailByName(bookName: String)
/**
* 获取指定书籍的最后一条阅读会话
* 用于判断是否可以合并
*/
@Query("SELECT * FROM readRecordSession WHERE bookName = :bookName ORDER BY endTime DESC LIMIT 1")
suspend fun getLatestSessionByBook(bookName: String): ReadRecordSession?
/**
* 更新现有的会话
*/
@Update
suspend fun updateSession(session: ReadRecordSession)
/**
* 插入阅读会话记录。
*/
@@ -15,10 +15,64 @@ class ReadRecordRepository(
private val dao: ReadRecordDao
) {
companion object {
// 2分钟内重新打开书本,视为同一次阅读
private const val CONTINUE_THRESHOLD = 120 * 1000L
// 会话总时长小于10秒则不记录
private const val MIN_READ_DURATION = 0 * 1000L
}
private fun getCurrentDeviceId(): String = ""
/**
* 保存一个完整的阅读会话,并同步更新 ReadRecordDetail 和 ReadRecord
* 智能保存阅读会话
* 自动判断是插入新会话还是合并到上一次会话
*/
@Transaction
suspend fun saveOrMergeReadSession(newSession: ReadRecordSession) {
val segmentDuration = newSession.endTime - newSession.startTime
if (segmentDuration < MIN_READ_DURATION) {
return
}
val latestSession = dao.getLatestSessionByBook(newSession.bookName)
if (latestSession != null) {
val timeGap = newSession.startTime - latestSession.endTime
if (timeGap <= CONTINUE_THRESHOLD && timeGap >= 0) {
val durationDelta = segmentDuration
val wordsDelta = newSession.words
val mergedSession = latestSession.copy(
endTime = newSession.endTime,
words = latestSession.words + wordsDelta
)
dao.updateSession(mergedSession)
val dateString = DateUtil.format(Date(mergedSession.startTime), DatePattern.NORM_DATE_PATTERN)
updateReadRecordDetail(mergedSession, durationDelta, wordsDelta, dateString)
updateReadRecord(mergedSession, durationDelta)
return
}
}
// 处理新会话
dao.insertSession(newSession)
val dateString = DateUtil.format(Date(newSession.startTime), DatePattern.NORM_DATE_PATTERN)
updateReadRecordDetail(newSession, segmentDuration, newSession.words, dateString)
updateReadRecord(newSession, segmentDuration)
}
/**
* 保存一个完整的阅读会话
* 没几把用
*/
@Transaction
suspend fun saveReadSession(session: ReadRecordSession) {
@@ -26,17 +80,23 @@ class ReadRecordRepository(
val sessionDuration = session.endTime - session.startTime
val dateString = DateUtil.format(Date(session.startTime), DatePattern.NORM_DATE_PATTERN)
updateReadRecordDetail(session, sessionDuration, session.words, dateString)
updateReadRecordDetail(session, sessionDuration, dateString)
updateReadRecord(session, sessionDuration)
}
private suspend fun updateReadRecord(session: ReadRecordSession, sessionDuration: Long) {
/**
* 更新总记录表 (ReadRecord)
* @param durationDelta 增加的时长
*/
private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
if (durationDelta <= 0) return
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
if (existingRecord != null) {
val updatedRecord = existingRecord.copy(
readTime = existingRecord.readTime + sessionDuration,
readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime
)
dao.update(updatedRecord)
@@ -44,19 +104,31 @@ class ReadRecordRepository(
val newRecord = ReadRecord(
deviceId = session.deviceId,
bookName = session.bookName,
readTime = sessionDuration,
readTime = durationDelta,
lastRead = session.endTime
)
dao.insert(newRecord)
}
}
private suspend fun updateReadRecordDetail(session: ReadRecordSession, sessionDuration: Long, dateString: String) {
/**
* 更新每日详情表 (ReadRecordDetail)
* @param durationDelta 增加的时长
* @param wordsDelta 增加的字数
*/
private suspend fun updateReadRecordDetail(
session: ReadRecordSession,
durationDelta: Long,
wordsDelta: Long,
dateString: String
) {
if (durationDelta <= 0 && wordsDelta <= 0) return
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
if (existingDetail != null) {
existingDetail.readTime += sessionDuration
existingDetail.readWords += session.words
existingDetail.readTime += durationDelta
existingDetail.readWords += wordsDelta
existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime)
existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
dao.insertDetail(existingDetail)
@@ -65,8 +137,8 @@ class ReadRecordRepository(
deviceId = session.deviceId,
bookName = session.bookName,
date = dateString,
readTime = sessionDuration,
readWords = session.words,
readTime = durationDelta,
readWords = wordsDelta,
firstReadTime = session.startTime,
lastReadTime = session.endTime
)
@@ -115,5 +187,4 @@ class ReadRecordRepository(
// 暴露总时长
val allTime: Long
get() = dao.allTime
}
@@ -47,6 +47,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
@@ -99,8 +100,20 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
var contentProcessor: ContentProcessor? = null
val downloadScope = CoroutineScope(SupervisorJob() + IO)
val preDownloadSemaphore = Semaphore(2)
val executor = globalExecutor
private val ioScope = CoroutineScope(IO)
private var autoSaveJob: Job? = null
private var currentActiveSession: ReadRecordSession? = null
//占位
private var currentReadLength: Long = 10L
private const val AUTO_SAVE_INTERVAL = 120 * 1000L
private const val MIN_READ_DURATION = 10 * 1000L
fun resetData(book: Book) {
ReadBook.book = book
readRecord.bookName = book.name
@@ -286,91 +299,84 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
}
}
// 阅读暂停的阈值
private const val PAUSE_THRESHOLD = 5 * 60 * 1000L
// 存储当前正在累积的阅读会话对象
private var currentActiveSession: ReadRecordSession? = null
//占位
private var currentReadLength: Long = 10L
fun upReadTime() {
val currentLength = currentReadLength
if (!AppConfig.enableReadRecord) {
return
}
fun initReadTime() {
val currentBookName = book?.name ?: return
val endTime = System.currentTimeMillis()
//计算本次片段的时长和字数
val duration = endTime - readStartTime
var wordChange = currentLength - lastReadLength
if (wordChange < 0) wordChange = 0
//过滤无效记录
if (duration < 1000L && wordChange == 0L) {
readStartTime = endTime
lastReadLength = currentLength
return
if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
commitReadSession()
}
//检查是否中断
if (currentActiveSession != null) {
// 计算从上一个片段结束到当前片段开始的时间间隔
val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime
if (timeSinceLastUpdate > PAUSE_THRESHOLD) {
// 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话
commitReadSession()
}
}
//累加或创建新会话
if (currentActiveSession == null) {
// 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间
lastReadLength = currentReadLength
currentActiveSession = ReadRecordSession(
deviceId = "",
bookName = currentBookName,
startTime = readStartTime,
endTime = endTime,
endTime = readStartTime,
words = 0
)
} else {
// 累加数据:只更新结束时间和总字数
currentActiveSession = currentActiveSession!!.copy(
endTime = endTime,
words = currentActiveSession!!.words + wordChange
)
}
}
fun upReadTime() {
val currentLength = currentReadLength
val currentBookName = book?.name ?: return
val endTime = System.currentTimeMillis()
if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
initReadTime()
return
}
var wordChange = currentLength - lastReadLength
if (wordChange < 0) wordChange = 0
currentActiveSession = currentActiveSession!!.copy(
endTime = endTime,
words = currentActiveSession!!.words + wordChange
)
readStartTime = endTime
lastReadLength = currentLength
}
/**
* 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。
* 此方法必须在后台线程上调用。
*/
fun commitReadSession() {
// 检查是否有需要保存的数据
val sessionToSave = currentActiveSession ?: return
fun startAutoSaveSession() {
autoSaveJob?.cancel()
autoSaveJob = ioScope.launch {
while (isActive) {
delay(AUTO_SAVE_INTERVAL)
commitSessionInternal()
}
}
}
// 确保时长和字数有效
if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) {
fun stopAutoSaveSession() {
autoSaveJob?.cancel()
autoSaveJob = null
}
fun commitReadSession() {
ioScope.launch {
commitSessionInternal()
}
}
/**
* 内部提交逻辑
*/
private suspend fun commitSessionInternal() {
val sessionToSave = currentActiveSession ?: return
val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
if (sessionDuration < MIN_READ_DURATION) {
currentActiveSession = null
return
}
//使用 runBlocking 确保数据库保存操作在当前线程上同步完成
try {
kotlinx.coroutines.runBlocking {
readRecordRepository.saveReadSession(sessionToSave)
}
readRecordRepository.saveOrMergeReadSession(sessionToSave)
} catch (e: Exception) {
AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e)
} finally {
currentActiveSession = null
}
currentActiveSession = null
}
fun upMsg(msg: String?) {
@@ -39,6 +39,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import org.koin.core.component.KoinComponent
@@ -75,6 +76,16 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
private val readRecordRepository: ReadRecordRepository by inject()
private val ioScope = CoroutineScope(IO)
private var autoSaveJob: Job? = null
private var currentActiveSession: ReadRecordSession? = null
private var currentReadLength: Long = 10L
private const val AUTO_SAVE_INTERVAL = 120 * 1000L
private const val MIN_READ_DURATION = 10 * 1000L
private var lastReadLength: Long = 0
fun resetData(book: Book) {
ReadManga.book = book
@@ -134,92 +145,84 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
nextMangaChapter = null
}
//每次切换章节更新阅读记录
// 阅读暂停的阈值
private const val PAUSE_THRESHOLD = 5 * 60 * 1000L
// 存储当前正在累积的阅读会话对象
private var currentActiveSession: ReadRecordSession? = null
//占位
private var currentReadLength: Long = 10L
fun upReadTime() {
val currentLength = currentReadLength
if (!AppConfig.enableReadRecord) {
return
}
fun initReadTime() {
val currentBookName = book?.name ?: return
val endTime = System.currentTimeMillis()
//计算本次片段的时长和字数
val duration = endTime - readStartTime
var wordChange = currentLength - lastReadLength
if (wordChange < 0) wordChange = 0
//过滤无效记录
if (duration < 1000L && wordChange == 0L) {
readStartTime = endTime
lastReadLength = currentLength
return
if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
commitReadSession()
}
//检查是否中断
if (currentActiveSession != null) {
// 计算从上一个片段结束到当前片段开始的时间间隔
val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime
if (timeSinceLastUpdate > PAUSE_THRESHOLD) {
// 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话
commitReadSession()
}
}
//累加或创建新会话
if (currentActiveSession == null) {
// 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间
lastReadLength = currentReadLength
currentActiveSession = ReadRecordSession(
deviceId = "",
bookName = currentBookName,
startTime = readStartTime,
endTime = endTime,
endTime = readStartTime,
words = 0
)
} else {
// 累加数据:只更新结束时间和总字数
currentActiveSession = currentActiveSession!!.copy(
endTime = endTime,
words = currentActiveSession!!.words + wordChange
)
}
}
fun upReadTime() {
val currentLength = currentReadLength
val currentBookName = book?.name ?: return
val endTime = System.currentTimeMillis()
if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
initReadTime()
return
}
var wordChange = currentLength - lastReadLength
if (wordChange < 0) wordChange = 0
currentActiveSession = currentActiveSession!!.copy(
endTime = endTime,
words = currentActiveSession!!.words + wordChange
)
readStartTime = endTime
lastReadLength = currentLength
}
/**
* 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。
* 此方法必须在后台线程上调用。
*/
fun commitReadSession() {
// 检查是否有需要保存的数据
val sessionToSave = currentActiveSession ?: return
fun startAutoSaveSession() {
autoSaveJob?.cancel()
autoSaveJob = ioScope.launch {
while (isActive) {
delay(AUTO_SAVE_INTERVAL)
commitSessionInternal()
}
}
}
// 确保时长和字数有效
if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) {
fun stopAutoSaveSession() {
autoSaveJob?.cancel()
autoSaveJob = null
}
fun commitReadSession() {
ioScope.launch {
commitSessionInternal()
}
}
/**
* 内部提交逻辑
*/
private suspend fun commitSessionInternal() {
val sessionToSave = currentActiveSession ?: return
val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
if (sessionDuration < MIN_READ_DURATION) {
currentActiveSession = null
return
}
//使用 runBlocking 确保数据库保存操作在当前线程上同步完成
try {
kotlinx.coroutines.runBlocking {
readRecordRepository.saveReadSession(sessionToSave)
}
readRecordRepository.saveOrMergeReadSession(sessionToSave)
} catch (e: Exception) {
AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e)
} finally {
currentActiveSession = null
}
currentActiveSession = null
}
@Synchronized
@@ -429,6 +429,8 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
if (enableAutoScroll) {
mScrollTimer.isEnabled = true
}
ReadManga.initReadTime()
ReadManga.startAutoSaveSession()
}
override fun onPause() {
@@ -444,6 +446,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
Backup.autoBack(this)
}
}
ReadManga.stopAutoSaveSession()
ReadManga.commitReadSession()
ReadManga.cancelPreDownloadTask()
networkChangedListener.unRegister()
@@ -392,6 +392,8 @@ class ReadBookActivity : BaseReadBookActivity(),
override fun onResume() {
super.onResume()
ReadBook.readStartTime = System.currentTimeMillis()
ReadBook.initReadTime()
ReadBook.startAutoSaveSession()
if (bookChanged) {
bookChanged = false
ReadBook.callBack = this
@@ -423,6 +425,7 @@ class ReadBookActivity : BaseReadBookActivity(),
autoPageStop()
backupJob?.cancel()
ReadBook.saveRead()
ReadBook.stopAutoSaveSession()
ReadBook.commitReadSession()
ReadBook.cancelPreDownloadTask()
unregisterReceiver(timeBatteryReceiver)
@@ -325,7 +325,7 @@ fun TimelineSessionItem(
val timelineX = 24.dp
val contentPaddingStart = 32.dp
val lineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
val lineColor = MaterialTheme.colorScheme.surfaceContainerHigh
val nodeColor = MaterialTheme.colorScheme.primary
Box(
@@ -364,7 +364,7 @@ fun TimelineSessionItem(
verticalArrangement = Arrangement.Center
) {
Text(
text = startTimeText,
text = endTimeText,
style = MaterialTheme.typography.bodySmall
)
}
@@ -385,7 +385,8 @@ fun TimelineSessionItem(
Text(
"时长: ${formatDuring(duration)}",
style = MaterialTheme.typography.bodySmall
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
// Text(
// "字数: ${session.words}",
@@ -0,0 +1,85 @@
package io.legado.app.ui.widget.compose
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun EmptyMessageView(
message: String,
modifier: Modifier = Modifier,
faces: List<String> = listOf(
"(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
"(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
),
faceTextSize: TextUnit = 32.sp,
onFaceClick: (() -> Unit)? = null
) {
var currentFace by remember { mutableStateOf(faces.random()) }
Column(
modifier = modifier
.wrapContentSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AnimatedTextLine(
text = currentFace,
fontSize = faceTextSize,
textAlign = TextAlign.Center,
modifier = Modifier
.clickable {
currentFace = faces.random()
onFaceClick?.invoke()
}
)
Spacer(modifier = Modifier.height(8.dp))
AnimatedTextLine(
text = message,
textAlign = TextAlign.Center,
maxLines = 2,
softWrap = true,
modifier = Modifier.widthIn(max = 240.dp)
)
}
}
@Composable
fun EmptyMessageView(
@StringRes messageResId: Int,
modifier: Modifier = Modifier,
faces: List<String> = listOf(
"(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
"(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
),
faceTextSize: TextUnit = 32.sp,
onFaceClick: (() -> Unit)? = null
) {
val message = stringResource(id = messageResId)
EmptyMessageView(
message = message,
modifier = modifier,
faces = faces,
faceTextSize = faceTextSize,
onFaceClick = onFaceClick
)
}