代码优化

This commit is contained in:
HapeLee
2026-05-13 21:22:44 +08:00
parent 4beeb2d4f6
commit 876f1370ec
21 changed files with 126200 additions and 331 deletions
+4 -1
View File
@@ -64,7 +64,10 @@
"PowerShell(npm *)", "PowerShell(npm *)",
"PowerShell(gcc *)", "PowerShell(gcc *)",
"Bash(gh repo *)", "Bash(gh repo *)",
"Bash(Test-Path *)" "Bash(Test-Path *)",
"Bash(git stash *)",
"Bash(Select-String -Pattern \"FAILED|error:\")",
"Bash(Select-Object -First 5)"
] ]
} }
} }
@@ -1,4 +1,4 @@
name: Test Build name: Auto Release
on: on:
push: push:
@@ -118,7 +118,7 @@ jobs:
echo "build_number=$NEW_BUILD_NUM" >> $GITHUB_OUTPUT echo "build_number=$NEW_BUILD_NUM" >> $GITHUB_OUTPUT
echo "commit_number=$COMMIT_COUNT" >> $GITHUB_OUTPUT echo "commit_number=$COMMIT_COUNT" >> $GITHUB_OUTPUT
echo "last_tag=$LAST_TAG_FOR_LOG" >> $GITHUB_OUTPUT echo "last_tag=$LAST_TAG_FOR_LOG" >> $GITHUB_OUTPUT
echo "is_release=$IS_RELEASE" >> $GITHUB_OUTPUT # 新的输出标志 echo "is_release=$IS_RELEASE" >> $GITHUB_OUTPUT # 新s的输出标志
build: build:
needs: prepare needs: prepare
-140
View File
@@ -1,140 +0,0 @@
name: Release Build
on:
# push:
# branches:
# - master
# paths:
# - 'CHANGELOG.md'
workflow_dispatch:
jobs:
prepare:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master'
outputs:
version: ${{ steps.set-ver.outputs.version }}
play: ${{ steps.check.outputs.play }}
sign: ${{ steps.check.outputs.sign }}
steps:
- id: set-ver
run: |
echo "version=$(date -d "8 hour" -u +3.%y.%m%d%H)" >> $GITHUB_OUTPUT
- id: check
run: |
if [ ! -z "${{ secrets.RELEASE_KEY_STORE }}" ]; then
echo "sign=yes" >> $GITHUB_OUTPUT
fi
if [ ! -z "${{ secrets.SERVICE_ACCOUNT_JSON }}" ]; then
echo "play=yes" >> $GITHUB_OUTPUT
fi
build:
needs: prepare
if: ${{ needs.prepare.outputs.sign }}
strategy:
matrix:
product: [ app, google ]
fail-fast: false
runs-on: ubuntu-latest
env:
product: ${{ matrix.product }}
VERSION: ${{ needs.prepare.outputs.version }}
play: ${{ needs.prepare.outputs.play }}
steps:
- uses: actions/checkout@v5
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 17
- name: Release Apk Sign
run: |
# not use this output
# echo "KeyStore=yes" >> $GITHUB_OUTPUT
echo -e "\n" >> gradle.properties
echo RELEASE_KEY_ALIAS='${{ secrets.RELEASE_KEY_ALIAS }}' >> gradle.properties
echo RELEASE_KEY_PASSWORD='${{ secrets.RELEASE_KEY_PASSWORD }}' >> gradle.properties
echo RELEASE_STORE_PASSWORD='${{ secrets.RELEASE_STORE_PASSWORD }}' >> gradle.properties
echo RELEASE_STORE_FILE='./key.jks' >> gradle.properties
echo ${{ secrets.RELEASE_KEY_STORE }} | base64 --decode > $GITHUB_WORKSPACE/app/key.jks
- name: Unify Version Name
run: |
echo "统一版本号"
sed "/def version/c def version = \"${{ env.VERSION }}\"" $GITHUB_WORKSPACE/app/build.gradle -i
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build With Gradle
run: |
echo "开始进行${{ env.product }}构建"
chmod +x gradlew
./gradlew assemble${{ env.product }}release --build-cache --parallel --daemon --warning-mode all
- name: Organize the Files
run: |
mkdir -p ${{ github.workspace }}/apk/
cp -rf ${{ github.workspace }}/app/build/outputs/apk/*/*/*.apk ${{ github.workspace }}/apk/
- name: Upload App To Artifact
uses: actions/upload-artifact@v4
with:
name: legado_${{ env.product }}
path: ${{ github.workspace }}/apk/*.apk
- name: Release
if: ${{ env.product == 'app' }}
uses: softprops/action-gh-release@v2
with:
name: legado_app_${{ env.VERSION }}
tag_name: ${{ env.VERSION }}
body_path: ${{ github.workspace }}/CHANGELOG.md
draft: false
prerelease: false
files: ${{ github.workspace }}/apk/legado_app_*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare For GooglePlay
if: env.product == 'google' && env.play == 'yes'
run: |
mkdir -p ReleaseNotes
ln -s ${{ github.workspace }}/CHANGELOG.md ReleaseNotes/whatsnew-en-US
ln -s ${{ github.workspace }}/CHANGELOG.md ReleaseNotes/whatsnew-zh-CN
- name: Release To GooglePlay
if: env.product == 'google' && env.play == 'yes'
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: io.legado.play
releaseFiles: ${{ github.workspace }}/apk/legado_google_*.apk
track: production
whatsNewDirectory: ${{ github.workspace }}/ReleaseNotes
- name: Push Assets To "release" Branch
if: ${{ github.actor == 'gedoor' }}
run: |
cd $GITHUB_WORKSPACE/apk/
git init
git checkout -b release
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git remote add origin "https://${{ github.actor }}:${{ secrets.ACTIONS_TOKEN }}@github.com/${{ github.actor }}/release"
git add *.apk
git commit -m "${{ env.VERSION }}"
git push -f -u origin release
- name: Purge Jsdelivr Cache
if: ${{ github.actor == 'gedoor' }}
run: |
result=$(curl -s https://purge.jsdelivr.net/gh/${{ github.actor }}/release@release/)
if echo $result |grep -q 'success.*true'; then
echo "jsdelivr缓存更新成功"
else
echo $result
fi
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import splitties.init.appCtx import splitties.init.appCtx
import splitties.systemservices.notificationManager import splitties.systemservices.notificationManager
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.min import kotlin.math.min
@@ -69,7 +70,7 @@ class CacheBookService : BaseService() {
private val downloadJobLock = Any() private val downloadJobLock = Any()
private var downloadJob: Job? = null private var downloadJob: Job? = null
private var notificationContent = appCtx.getString(R.string.service_starting) private var notificationContent = appCtx.getString(R.string.service_starting)
private var mutex = Mutex() private val bookLocks = ConcurrentHashMap<String, Mutex>()
private var lastDiagnosticsLogTime = 0L private var lastDiagnosticsLogTime = 0L
private val notificationBuilder by lazy { private val notificationBuilder by lazy {
val builder = NotificationCompat.Builder(this, AppConst.channelIdDownload) val builder = NotificationCompat.Builder(this, AppConst.channelIdDownload)
@@ -297,7 +298,8 @@ class CacheBookService : BaseService() {
if (chapterCount == 0) { if (chapterCount == 0) {
cacheBook.setLoading() cacheBook.setLoading()
mutex.withLock { val bookMutex = bookLocks.getOrPut(request.bookUrl) { Mutex() }
bookMutex.withLock {
val name = book.name val name = book.name
if (!admission.isCurrent()) { if (!admission.isCurrent()) {
CacheBook.removeModelFromService(request.bookUrl, cacheBook) CacheBook.removeModelFromService(request.bookUrl, cacheBook)
@@ -371,8 +373,8 @@ class CacheBookService : BaseService() {
} }
private fun admittedBookUrls(): Set<String> { private fun admittedBookUrls(): Set<String> {
return CacheBook.cacheBookMap.keys.toHashSet().apply { return synchronized(admissionLock) {
synchronized(admissionLock) { CacheBook.cacheBookMap.keys.toHashSet().apply {
addAll(admittingBookUrls) addAll(admittingBookUrls)
} }
} }
@@ -431,21 +433,24 @@ class CacheBookService : BaseService() {
} }
private fun finishAdmissionJob(bookUrl: String) { private fun finishAdmissionJob(bookUrl: String) {
var restart = false
val waiters = synchronized(admissionLock) { val waiters = synchronized(admissionLock) {
val buffer = admissionBuffers[bookUrl] val buffer = admissionBuffers[bookUrl]
if (buffer != null && buffer.isNotEmpty()) { if (buffer != null && buffer.isNotEmpty()) {
restart = true // New requests arrived while job was finishing — restart immediately.
emptyList() startAdmissionJob(bookUrl)
} else { return
admissionBuffers.remove(bookUrl)
admittingBookUrls.remove(bookUrl)
admissionIdleWaiters.remove(bookUrl).orEmpty()
} }
} admissionBuffers.remove(bookUrl)
if (restart) { admittingBookUrls.remove(bookUrl)
startAdmissionJob(bookUrl) // Re-check: a concurrent submitDownloadRequest may have added a request
return // between the buffer empty-check and the admittingBookUrls removal.
val recheck = admissionBuffers[bookUrl]
if (recheck != null && recheck.isNotEmpty()) {
admittingBookUrls.add(bookUrl)
startAdmissionJob(bookUrl)
return
}
admissionIdleWaiters.remove(bookUrl).orEmpty()
} }
waiters.forEach { it.complete(Unit) } waiters.forEach { it.complete(Unit) }
} }
@@ -480,26 +485,23 @@ class CacheBookService : BaseService() {
private suspend fun runDownloadLoop() { private suspend fun runDownloadLoop() {
try { try {
var lastActiveTime = System.currentTimeMillis() var idleSince = -1L
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
drainPendingDownloadRequests() drainPendingDownloadRequests()
if (CacheBook.isGloballyPaused) { if (CacheBook.isGloballyPaused) {
break break
} }
val isActiveNow = CacheBook.isRun if (CacheBook.isRun) {
|| hasPendingDownloadRequests() idleSince = -1L
|| hasAdmittingRequests() CacheBook.startProcessJob(cachePool)
if (isActiveNow) {
lastActiveTime = System.currentTimeMillis()
} else {
break
}
if (!CacheBook.isRun) {
if (System.currentTimeMillis() - lastActiveTime > MAX_IDLE_SPIN_MS) break
delay(200)
continue continue
} }
CacheBook.startProcessJob(cachePool) // !isRun — only keep looping while there is pending/admitting work.
if (!hasPendingDownloadRequests() && !hasAdmittingRequests()) break
val now = System.currentTimeMillis()
if (idleSince < 0) idleSince = now
if (now - idleSince > MAX_IDLE_SPIN_MS) break
delay(200)
} }
} finally { } finally {
val finishedJob = currentCoroutineContext()[Job] val finishedJob = currentCoroutineContext()[Job]
@@ -136,7 +136,9 @@ fun BookInfoScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable @Composable
private fun BookInfoScreenContent( private fun BookInfoScreenContent(
state: BookInfoUiState, state: BookInfoUiState,
@@ -2,6 +2,7 @@ package io.legado.app.ui.config.themeManage
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -29,6 +30,7 @@ import androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.help.config.ThemeExportData import io.legado.app.help.config.ThemeExportData
import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.button.MediumIconButton import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -74,7 +76,8 @@ fun EditThemeSheet(
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.navigationBarsPadding() .navigationBarsPadding()
.padding(bottom = 32.dp) .padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Name // Name
AppTextField( AppTextField(
@@ -84,7 +87,6 @@ fun EditThemeSheet(
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -5,18 +5,21 @@ import android.os.Looper
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Brush
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@@ -119,14 +122,17 @@ fun ThemeManageScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
LazyColumn( LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPadding( contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(), top = paddingValues.calculateTopPadding(),
bottom = 120.dp bottom = 120.dp
) ),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
item { item(span = { GridItemSpan(maxLineSpan) }) {
SplicedColumnGroup { SplicedColumnGroup {
ClickableSettingItem( ClickableSettingItem(
title = "保存当前设置", title = "保存当前设置",
@@ -155,12 +161,12 @@ fun ThemeManageScreen(
} }
if (savedThemes.isNotEmpty()) { if (savedThemes.isNotEmpty()) {
item { item(span = { GridItemSpan(maxLineSpan) }) {
AppText( AppText(
text = "已保存的主题", text = "已保存的主题",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp) modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
) )
} }
@@ -288,79 +294,112 @@ private fun SavedThemeItem(
) { ) {
GlassCard( GlassCard(
onClick = onApply, onClick = onApply,
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
cornerRadius = 20.dp
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
val previewColor = if (theme.data.themeColor != 0) { val lightPrimary = if (theme.data.themeColor != 0) Color(theme.data.themeColor)
Color(theme.data.themeColor) else if (theme.data.cPrimary != 0) Color(theme.data.cPrimary)
} else if (theme.data.cPrimary != 0) { else MaterialTheme.colorScheme.primary
Color(theme.data.cPrimary)
} else { val darkPrimary = if (theme.data.cNPrimary != 0) Color(theme.data.cNPrimary)
MaterialTheme.colorScheme.primary else lightPrimary
val lightBg = if (theme.data.themeBackgroundColor != 0) Color(theme.data.themeBackgroundColor)
else Color(0xFFF7F2FA)
val darkBg = if (theme.data.isPureBlack) Color.Black else Color(0xFF1C1B1F)
// 预览区域
Column(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.2f)
) {
// 日间行
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(
Brush.horizontalGradient(
0.5f to lightBg,
0.8f to lightPrimary
)
)
) {
AppText(
text = "日间",
style = MaterialTheme.typography.labelMediumEmphasized,
color = if (theme.data.primaryTextColor != 0) Color(theme.data.primaryTextColor).copy(alpha = 0.6f)
else Color.Black.copy(alpha = 0.5f),
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp)
)
}
// 夜间行
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(
Brush.horizontalGradient(
0.5f to darkBg,
0.8f to darkPrimary
)
)
) {
AppText(
text = "夜间",
style = MaterialTheme.typography.labelMediumEmphasized,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp)
)
}
} }
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(previewColor)
)
Spacer(modifier = Modifier.width(12.dp))
Column( Column(
modifier = Modifier.weight(1f) modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
) { ) {
AppText( AppText(
text = theme.name, text = theme.name,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.titleSmall,
maxLines = 1
) )
val features = mutableListOf<String>()
features.add(
when (theme.data.appTheme) {
"0" -> "动态取色"
"12" -> "自定义颜色"
else -> "预设主题"
}
)
if (theme.data.enableBlur) features.add("模糊效果")
if (theme.data.useFloatingBottomBar) features.add("浮动底栏")
AppText(
text = features.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onEdit) { Row(
Icon( modifier = Modifier.fillMaxWidth(),
imageVector = Icons.Default.Edit, horizontalArrangement = Arrangement.End,
contentDescription = "编辑", verticalAlignment = Alignment.CenterVertically
modifier = Modifier.size(20.dp) ) {
) IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
} Icon(
IconButton(onClick = onExport) { imageVector = Icons.Default.Edit,
Icon( contentDescription = "编辑",
imageVector = Icons.Default.Share, modifier = Modifier.size(18.dp)
contentDescription = "导出", )
modifier = Modifier.size(20.dp) }
) IconButton(onClick = onExport, modifier = Modifier.size(32.dp)) {
} Icon(
IconButton(onClick = onDelete) { imageVector = Icons.Default.Share,
Icon( contentDescription = "导出",
imageVector = Icons.Default.Delete, modifier = Modifier.size(18.dp)
contentDescription = "删除", )
modifier = Modifier.size(20.dp), }
tint = MaterialTheme.colorScheme.error IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
) Icon(
imageVector = Icons.Default.Delete,
contentDescription = "删除",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
} }
} }
} }
@@ -55,6 +55,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -240,6 +241,7 @@ fun MainScreen(
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
WideNavigationRailItem( WideNavigationRailItem(
modifier = Modifier.testTag("nav_${destination.route}"),
railExpanded = navState.targetValue == WideNavigationRailValue.Expanded, railExpanded = navState.targetValue == WideNavigationRailValue.Expanded,
selected = selected, selected = selected,
onClick = { onClick = {
@@ -308,6 +310,7 @@ fun MainScreen(
MainDestination.My -> ThemeConfig.navIconMy MainDestination.My -> ThemeConfig.navIconMy
} }
AppNavigationBarItem( AppNavigationBarItem(
modifier = Modifier.testTag("nav_${destination.route}"),
selected = selected, selected = selected,
onClick = { onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) } coroutineScope.launch { pagerState.animateScrollToPage(index) }
@@ -5,6 +5,7 @@ import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
@@ -81,6 +82,7 @@ import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@@ -145,6 +147,8 @@ fun BookshelfScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
ReportDrawnWhen { uiState.groups.isNotEmpty() }
val activeOverlay = uiState.activeOverlay val activeOverlay = uiState.activeOverlay
val showGroupMenu = activeOverlay == BookshelfOverlay.GroupMenu val showGroupMenu = activeOverlay == BookshelfOverlay.GroupMenu
val isEditMode = uiState.isEditMode val isEditMode = uiState.isEditMode
@@ -1215,6 +1219,7 @@ fun BookshelfPage(
state = gridState, state = gridState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.testTag("bookshelf_list")
.then( .then(
with(sharedTransitionScope) { with(sharedTransitionScope) {
if (this != null) Modifier.skipToLookaheadSize() else Modifier if (this != null) Modifier.skipToLookaheadSize() else Modifier
+8 -16
View File
@@ -1,5 +1,3 @@
import com.android.build.api.dsl.ManagedVirtualDevice
plugins { plugins {
alias(libs.plugins.android.test) alias(libs.plugins.android.test)
alias(libs.plugins.baselineprofile) alias(libs.plugins.baselineprofile)
@@ -20,7 +18,7 @@ android {
defaultConfig { defaultConfig {
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -31,24 +29,13 @@ android {
productFlavors { productFlavors {
create("app") { dimension = "mode" } create("app") { dimension = "mode" }
} }
// This code creates the gradle managed device used to generate baseline profiles.
// To use GMD please invoke generation through the command line:
// ./gradlew :app:generateBaselineProfile
testOptions.managedDevices.allDevices {
create<ManagedVirtualDevice>("pixel6Api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "google"
}
}
} }
// This is the configuration block for the Baseline Profile plugin. // This is the configuration block for the Baseline Profile plugin.
// You can specify to run the generators on a managed devices or connected devices. // You can specify to run the generators on a managed devices or connected devices.
baselineProfile { baselineProfile {
managedDevices += "pixel6Api34" // managedDevices += "pixel6Api34"
useConnectedDevices = false useConnectedDevices = true
} }
dependencies { dependencies {
@@ -59,6 +46,11 @@ dependencies {
} }
androidComponents { androidComponents {
beforeVariants { v ->
if (v.buildType == "noR8" || v.buildType == "debug") {
v.enable = false
}
}
onVariants { v -> onVariants { v ->
val artifactsLoader = v.artifacts.getBuiltArtifactsLoader() val artifactsLoader = v.artifacts.getBuiltArtifactsLoader()
v.instrumentationRunnerArguments.put( v.instrumentationRunnerArguments.put(
@@ -4,32 +4,15 @@ import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
/** /**
* This test class generates a basic startup baseline profile for the target package. * This test class generates a basic startup baseline profile for the target package.
*
* We recommend you start with this but add important user flows to the profile to improve their performance.
* Refer to the [baseline profile documentation](https://d.android.com/topic/performance/baselineprofiles)
* for more information.
*
* You can run the generator with the "Generate Baseline Profile" run configuration in Android Studio or
* the equivalent `generateBaselineProfile` gradle task:
* ```
* ./gradlew :app:generateReleaseBaselineProfile
* ```
* The run configuration runs the Gradle task and applies filtering to run only the generators.
*
* Check [documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args)
* for more information about available instrumentation arguments.
*
* After you run the generator, you can verify the improvements running the [StartupBenchmarks] benchmark.
*
* When using this class to generate a baseline profile, only API 33+ or rooted API 28+ are supported.
*
* The minimum required version of androidx.benchmark to generate a baseline profile is 1.2.0.
**/ **/
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
@@ -40,29 +23,43 @@ class BaselineProfileGenerator {
@Test @Test
fun generate() { fun generate() {
// The application id for the running build variant is read from the instrumentation arguments. val packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
rule.collect( ?: "io.legado.app"
packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
?: throw Exception("targetAppId not passed as instrumentation runner arg"),
// See: https://d.android.com/topic/performance/baselineprofiles/dex-layout-optimizations rule.collect(
packageName = packageName,
includeInStartupProfile = true includeInStartupProfile = true
) { ) {
// This block defines the app's critical user journey. Here we are interested in // 1. 启动应用
// optimizing for app startup. But you can also navigate and scroll through your most important UI.
// Start default activity for your app
pressHome() pressHome()
startActivityAndWait() startActivityAndWait()
// TODO Write more interactions to optimize advanced journeys of your app. // 2. 等待书架加载 (我们在 BookshelfScreen 中添加了 ReportDrawnWhen)
// For example: // 等待书架列表 testTag 出现
// 1. Wait until the content is asynchronously loaded device.wait(Until.hasObject(By.res(packageName, "bookshelf_list")), 10000)
// 2. Scroll the feed content
// 3. Navigate to detail screen // 3. 模拟滑动书架 (优化列表渲染性能)
val bookshelf = device.findObject(By.res(packageName, "bookshelf_list"))
bookshelf?.apply {
setGestureMargin(device.displayWidth / 10)
fling(Direction.DOWN)
device.waitForIdle()
fling(Direction.UP)
device.waitForIdle()
}
// 4. 遍历主要 Tab (优化 Compose 导航和各页面初始化)
val tabs = listOf("nav_explore", "nav_rss", "nav_my", "nav_bookshelf")
for (tabTag in tabs) {
val tab = device.findObject(By.res(packageName, tabTag))
if (tab != null) {
tab.click()
device.waitForIdle()
// 稍微等待页面加载
Thread.sleep(500)
}
}
// Check UiAutomator documentation for more information how to interact with the app.
// https://d.android.com/training/testing/other-components/ui-automator
} }
} }
} }
@@ -4,10 +4,14 @@ import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -49,27 +53,32 @@ class StartupBenchmarks {
private fun benchmark(compilationMode: CompilationMode) { private fun benchmark(compilationMode: CompilationMode) {
// The application id for the running build variant is read from the instrumentation arguments. // The application id for the running build variant is read from the instrumentation arguments.
val packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
?: "io.legado.app"
rule.measureRepeated( rule.measureRepeated(
packageName = InstrumentationRegistry.getArguments().getString("targetAppId") packageName = packageName,
?: throw Exception("targetAppId not passed as instrumentation runner arg"), metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode, compilationMode = compilationMode,
startupMode = StartupMode.COLD, startupMode = StartupMode.COLD,
iterations = 10, iterations = 3,
setupBlock = { setupBlock = {
pressHome() pressHome()
}, },
measureBlock = { measureBlock = {
startActivityAndWait() startActivityAndWait()
// TODO Add interactions to wait for when your app is fully drawn. // 等待书架列表加载完成
// The app is fully drawn when Activity.reportFullyDrawn is called. // 我们在 BookshelfScreen 中添加了 testTag("bookshelf_list")
// For Jetpack Compose, you can use ReportDrawn, ReportDrawnWhen and ReportDrawnAfter device.wait(Until.hasObject(By.res(packageName, "bookshelf_list")), 10000)
// from the AndroidX Activity library.
// Check the UiAutomator documentation for more information on how to // 模拟交互:在书架上进行滑动,以确保 Profile 捕获到列表渲染和图片加载的路径
// interact with the app. val bookshelfList = device.findObject(By.res(packageName, "bookshelf_list"))
// https://d.android.com/training/testing/other-components/ui-automator bookshelfList?.apply {
setGestureMargin(device.displayWidth / 10)
fling(Direction.DOWN)
device.waitForIdle()
}
} }
) )
} }
+2
View File
@@ -15,6 +15,8 @@ plugins {
alias(libs.plugins.google.services) apply false alias(libs.plugins.google.services) apply false
alias(libs.plugins.room) apply false alias(libs.plugins.room) apply false
alias(libs.plugins.download) apply false alias(libs.plugins.download) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.baselineprofile) apply false
} }
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
+2 -2
View File
@@ -45,5 +45,5 @@ android.uniquePackageNames=false
android.dependency.useConstraints=true android.dependency.useConstraints=true
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
android.r8.strictFullModeForKeepRules=false android.r8.strictFullModeForKeepRules=false
org.gradle.configuration-cache=true
android.newDsl=false
+26 -18
View File
@@ -1,27 +1,27 @@
[versions] [versions]
accompanistWebview = "0.36.0" accompanistWebview = "0.36.0"
adaptive = "1.3.0-alpha10" adaptive = "1.3.0-beta01"
adaptiveLayout = "1.3.0-alpha10" adaptiveLayout = "1.3.0-beta01"
adaptiveNavigation = "1.3.0-alpha10" adaptiveNavigation = "1.3.0-beta01"
animation = "1.10.6" animation = "1.11.1"
biometric = "1.4.0-alpha06" biometric = "1.4.0-alpha06"
coilCompose = "2.7.0" coilCompose = "2.7.0"
composeBom = "2026.03.01" composeBom = "2026.05.00"
constraintlayoutCompose = "1.1.1" constraintlayoutCompose = "1.1.1"
coreSplashscreen = "1.2.0" coreSplashscreen = "1.2.0"
datastorePreferences = "1.2.1" datastorePreferences = "1.2.1"
foundation = "1.10.6" foundation = "1.11.1"
kotlin = "2.3.10" kotlin = "2.3.10"
kotlinxSerialization = "1.10.0" kotlinxSerialization = "1.11.0"
kotlinxCoroutinesAndroid = "1.10.2" kotlinxCoroutinesAndroid = "1.10.2"
kotlinxSerializationJson = "1.10.0" kotlinxSerializationJson = "1.11.0"
kotlinxCollectionsImmutable = "0.4.0" kotlinxCollectionsImmutable = "0.4.0"
ksp = "2.3.6" ksp = "2.3.6"
agp = "9.1.1" agp = "9.2.1"
appcompat = "1.7.1" appcompat = "1.7.1"
colorpicker = "1.1.0" colorpicker = "1.1.0"
colorpicker-compose = "1.1.3" colorpicker-compose = "1.1.4"
commonsText = "1.15.0" commonsText = "1.15.0"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
core = "1.18.0" core = "1.18.0"
@@ -34,7 +34,7 @@ hutool = "5.8.22"
libarchive = "1.1.6" libarchive = "1.1.6"
lifecycle = "2.10.0" lifecycle = "2.10.0"
glide = "5.0.5" glide = "5.0.7"
gson = "2.13.2" gson = "2.13.2"
jsonPath = "2.10.0" jsonPath = "2.10.0"
# issue #3811,不要更新版本,新版引入了一个破坏性变更(详见https://github.com/jhy/jsoup/pull/2017 # issue #3811,不要更新版本,新版引入了一个破坏性变更(详见https://github.com/jhy/jsoup/pull/2017
@@ -44,17 +44,17 @@ jsoupxpath = "2.5.3"
coroutines = "1.10.2" coroutines = "1.10.2"
lifecycleViewmodelCompose = "2.10.0" lifecycleViewmodelCompose = "2.10.0"
liveeventbus = "1.8.14" liveeventbus = "1.8.14"
navigation3 = "1.0.1" navigation3 = "1.1.1"
markdownCompose = "0.5.0" markdownCompose = "0.5.0"
markwon = "4.6.2" markwon = "4.6.2"
material = "1.14.0-beta01" material = "1.14.0-rc01"
material3 = "1.5.0-alpha17" material3 = "1.5.0-alpha19"
material3IconsExtended = "1.7.8" material3IconsExtended = "1.7.8"
materialKolor = "4.1.1" materialKolor = "4.1.1"
media = "1.7.1" media = "1.7.1"
media3 = "1.8.0" media3 = "1.8.0"
nanoHttpd = "2.3.1" nanoHttpd = "2.3.1"
navigationCompose = "2.9.7" navigationCompose = "2.9.8"
okhttp = "5.3.2" okhttp = "5.3.2"
palette = "1.0.0" palette = "1.0.0"
preference = "1.2.1" preference = "1.2.1"
@@ -74,21 +74,25 @@ recyclerview = "1.4.0"
#noinspection GradleDependency #noinspection GradleDependency
uiViewbinding = "1.9.4" uiViewbinding = "1.9.4"
viewpager2 = "1.1.0" viewpager2 = "1.1.0"
webkit = "1.15.0" webkit = "1.16.0"
collection = "1.6.0" collection = "1.6.0"
zxingLite = "3.3.0" zxingLite = "3.3.0"
koin-bom = "4.2.0" koin-bom = "4.2.1"
biometricKtx = "1.1.0" biometricKtx = "1.1.0"
reorderable = "3.0.0" reorderable = "3.1.0"
haze = "1.7.2" haze = "1.7.2"
timber = "5.0.1" timber = "5.0.1"
miuix = "0.9.0" miuix = "0.9.0"
backdrop = "1.0.6" backdrop = "1.0.6"
capsule = "2.1.3" capsule = "2.1.3"
lyricViewx = "1.3.2" lyricViewx = "1.3.2"
uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.4.1"
baselineprofile = "1.4.1"
profileinstaller = "1.4.1"
[libraries] [libraries]
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" } accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" }
@@ -260,6 +264,9 @@ miuix-ui-android = { module = "top.yukonga.miuix.kmp:miuix-ui-android", version.
capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" } capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" }
backdrop = { module = "io.github.kyant0:backdrop", version.ref = "backdrop" } backdrop = { module = "io.github.kyant0:backdrop", version.ref = "backdrop" }
lyricViewx = { module = "com.github.Moriafly:LyricViewX", version.ref = "lyricViewx" } lyricViewx = { module = "com.github.Moriafly:LyricViewX", version.ref = "lyricViewx" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
[bundles] [bundles]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"] coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
@@ -291,3 +298,4 @@ room = { id = "androidx.room", version.ref = "room" }
#protobuf = { id = "com.google.protobuf", version = "0.9.4" } #protobuf = { id = "com.google.protobuf", version = "0.9.4" }
download = { id = "de.undercouch.download", version = "5.6.0" } download = { id = "de.undercouch.download", version = "5.6.0" }
baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+1
View File
@@ -53,3 +53,4 @@ rootProject.name = 'legado'
include ':app' include ':app'
include ':modules:book' include ':modules:book'
include ':modules:rhino' include ':modules:rhino'
include ':baselineprofile'