Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
406 changes: 235 additions & 171 deletions .github/workflows/android-apk.yml

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions docs/android-build-time-research-1103.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Android APK 编译耗时调研(#1103)

调研日期:2026-09-25。基线:上游 beta `d9113e0b03c0b5998079d5f43dcb33fb8bba50e7`。仅调研,未修改构建实现。

## 实测证据

- [tag run 35989822568](https://github.com/Open-Less/openless/actions/runs/35989822568):job 32 分 32 秒;Build Android release APK 30 分 31 秒。
- Cargo 完成记录为 6m01s、2m14s、5m11s、5m27s、5m37s。前两轮都为 aarch64,第二轮重新编译应用及部分 Tauri crates。重复编译事实已确认,失效原因尚未确认。
- [同 SHA 的 beta dispatch 35985550453](https://github.com/Open-Less/openless/actions/runs/35985550453)也有五轮编译;arm64 第二轮 2m15s。
- tag Gradle 日志明确 cache-read-only=true;仓库默认分支为 beta,beta dispatch 为 false。不能用 post 为零秒单独证明清盘阻止保存。
- beta dispatch 在清盘后仍保存约 171 MB Rust cache,随后 tag 命中同一缓存。这不证明 target 编译产物被保留:workflow 明确提前删除 target、Cargo registry/git 和 Gradle caches。
- 独立前端构建约 15 秒;Tauri beforeBuildCommand 随后再次执行 npm run build。

## 建议顺序

1. 修复清盘与缓存生命周期,并更换缓存版本键,避免继续命中已有残缺缓存;验证连续两次运行的实际缓存内容及编译耗时。post action 在普通 steps 之后执行,仅把清盘移动到普通 steps 末尾无效。
2. 日常 dispatch 可选 ABI,默认 arm64;正式 tag 保留全 ABI。进一步使用 ABI matrix 降低全量墙钟,分别衡量总 runner 分钟和排队时间;发布资产集中汇总,校验 ABI 完整性。
3. 记录 Cargo timings/fingerprint 和两轮 arm64 调用参数、环境、生成文件差异,定位重复编译。不要直接绕过 Tauri/Gradle native 构建。
4. 当前 release 使用 opt-level=3、thin LTO、codegen-units=1。试验仅 dispatch 的快速 profile:关闭 LTO、提高 codegen-units,同时保持签名;单独比较 APK 大小、运行表现和耗时。正式 tag 优化配置暂不改变。
5. ci-disable-macos-qwen3.mjs 每次删除平台依赖后 cargo generate-lockfile。应研究保留已锁定版本的确定性处理,避免无关依赖升级及缓存键变化;stable Rust 也应考虑固定版本并有计划升级。
6. Cargo timings 若表明依赖或主 crate 占比高,再审计 Android 不使用的依赖/features、缩小重编译边界。现有 openless-core 可作为评估起点,不能未经测量就大改架构。
7. 次要优化:删除重复前端构建,验证 Gradle task-output cache,更强的 x64 runner、预置工具链环境,以及 artifact 压缩参数。现有四份上传合计约 14 秒,优先级低。

## 限制与官方资料

- [Cargo profiles](https://doc.rust-lang.org/cargo/reference/profiles.html):LTO/codegen-units 是构建速度与产物表现之间的取舍,不能承诺未经实测的收益。
- [rust-cache](https://github.com/Swatinem/rust-cache):默认排除 workspace crates、清理 incremental;缓存命中不等于应用无需重编译。
- [sccache Rust](https://github.com/mozilla/sccache/blob/main/docs/Rust.md):不能缓存涉及系统链接的 cdylib 等输出,因此不是 Tauri 主库的万能缓存;可单独评估依赖缓存收益。
- [Gradle Actions v4](https://github.com/gradle/actions/blob/v4/docs/setup-gradle.md)与[GitHub cache scope](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching):默认分支写缓存与 ref 可见性应一并设计。
- [Gradle build cache](https://docs.gradle.org/current/userguide/build_cache.html):任务输出缓存和依赖缓存不同;仅缓存输入输出定义完整的任务。
- [Android NDK host](https://developer.android.com/ndk/guides/other_build_systems):Linux 官方 NDK 使用 x86_64 host 工具链,不能因为目标 APK 是 arm64 就直接换 Linux arm64 runner。
- [Tauri CLI](https://v2.tauri.app/reference/cli/):Android build 会执行 beforeBuildCommand。

尚未运行优化版本 CI,因此所有优化收益均为待验证假设。基准应覆盖冷缓存、相同依赖下的源码改动、依赖变更,以及正式全 ABI 构建;同时检查签名、APK ABI 和 updater manifest 完整性。

## 实现备注(#1103 跟进)

重复 aarch64 根因已定位:Tauri CLI `android build` 在 `apk::build` 之前会对**第一个** target 调用 `first_target.build(...)`(注释为 initialize plugins),随后 Gradle 再对每个 ABI 执行 `tauri android android-studio-script`。因此首个 ABI 必然两次 cargo;第二轮约 2m 是因为 `write_options` / `inject_resources` 发生在首次编译之后,指纹变脏。不要绕过 Gradle/native 路径;用单 ABI dispatch + 全 ABI matrix 并行降低墙钟。
14 changes: 13 additions & 1 deletion openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9447,7 +9447,19 @@ mod tests {
}
}
backend.cancel_dictation(Some(first)).await.unwrap();
let second = backend.start_dictation().await.unwrap();
let second = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
match backend.start_dictation().await {
Ok(id) => break id,
Err(error) if error.code == BackendErrorCode::Busy => {
tokio::task::yield_now().await;
}
Err(error) => panic!("unexpected start failure: {error}"),
}
}
})
.await
.expect("successor dictation should become available after cancel");
release_guard.release();
settings.join().unwrap().unwrap();
stopping.await.unwrap().unwrap();
Expand Down
68 changes: 68 additions & 0 deletions openless-all/app/linux-egui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2474,6 +2474,39 @@ mod linux_app {
});
}

/// Phone-input status panel used by the CA fingerprint contract test.
/// Keep the fingerprint copy aligned with the desktop Remote Input settings.
fn remote_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("手机输入");
if let Some((remote, _pin)) = &self.remote_access {
if remote.enabled && remote.running && !remote.urls_stale {
// 首次信任前必须核对根证书指纹:网页与描述文件名称不能证明身份。
ui.label("本机根证书 SHA-256");
match remote.ca_fingerprint_sha256.as_ref().filter(|value| {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}) {
Some(fingerprint) => {
let display = fingerprint
.as_bytes()
.chunks(2)
.map(|pair| std::str::from_utf8(pair).unwrap().to_ascii_uppercase())
.collect::<Vec<_>>()
.join(" ");
ui.add(
egui::Label::new(egui::RichText::new(&display).monospace()).wrap(),
);
if ui.button("复制完整指纹").clicked() {
ui.ctx().copy_text(display);
}
}
None => {
ui.label("完整指纹不可用。请勿安装或信任下载的证书。");
}
}
}
}
}

fn provider_management_ui(&mut self, ui: &mut egui::Ui) {
let lang = self.lang;
ui.horizontal(|ui| {
Expand Down Expand Up @@ -5457,6 +5490,41 @@ mod linux_app {
mod tests {
use super::*;

fn disconnected_app() -> OpenLessEguiApp {
OpenLessEguiApp::new(
Arc::new(tokio::runtime::Runtime::new().unwrap()),
Err("fixture: plugin unavailable".into()),
None,
LinuxUpdateSupport::ManualOnly {
releases_url: openless_linux_egui::RELEASES_URL,
},
)
}

fn rendered_text(mut draw: impl FnMut(&mut egui::Ui)) -> String {
let ctx = egui::Context::default();
ctx.begin_pass(egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(720.0, 1800.0),
)),
..Default::default()
});
egui::CentralPanel::default().show(&ctx, |ui| {
draw(ui);
});
let output = ctx.end_pass();
output
.shapes
.into_iter()
.filter_map(|clipped| match clipped.shape {
egui::epaint::Shape::Text(text) => Some(text.galley.text().to_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}

#[test]
fn running_remote_status_shows_ca_fingerprint_or_unavailable_warning() {
let mut app = disconnected_app();
Expand Down
7 changes: 4 additions & 3 deletions openless-all/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
"tauri": "tauri",
"tauri:android:init": "tauri android init",
"tauri:android:dev": "tauri android dev",
"tauri:android:build": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi",
"tauri:android:build:debug": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi",
"tauri:android:build:release": "tauri android build --apk --target aarch64 armv7 i686 x86_64 --split-per-abi",
"tauri:android:build": "node scripts/run-android-tauri-build.mjs --debug",
"tauri:android:build:debug": "node scripts/run-android-tauri-build.mjs --debug",
"tauri:android:build:release": "node scripts/run-android-tauri-build.mjs --release",
"check:android-apk-workflow": "node scripts/android-apk-workflow-contract.test.mjs",
"merge:android-v1-manifest": "node scripts/merge-android-v1-manifest.mjs",
"merge:android-overlay-manifest": "node scripts/merge-android-overlay-manifest.mjs",
"merge:android-shizuku-manifest": "node scripts/merge-android-shizuku-manifest.mjs",
Expand Down
123 changes: 123 additions & 0 deletions openless-all/app/scripts/android-abi-matrix.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Android ABI matrix helpers for CI (#1103).
*
* CLI target names match `tauri android build --target`.
* Gradle ABI folder names match APK lib/ layout.
*/

export const ANDROID_ABI_MATRIX = [
{
abi: 'aarch64',
rustTarget: 'aarch64-linux-android',
gradleAbi: 'arm64-v8a',
outputKey: 'arm64_v8a',
},
{
abi: 'armv7',
rustTarget: 'armv7-linux-androideabi',
gradleAbi: 'armeabi-v7a',
outputKey: 'armeabi_v7a',
},
{
abi: 'i686',
rustTarget: 'i686-linux-android',
gradleAbi: 'x86',
outputKey: 'x86',
},
{
abi: 'x86_64',
rustTarget: 'x86_64-linux-android',
gradleAbi: 'x86_64',
outputKey: 'x86_64',
},
];

const BY_ABI = new Map(ANDROID_ABI_MATRIX.map((entry) => [entry.abi, entry]));
const BY_GRADLE = new Map(ANDROID_ABI_MATRIX.map((entry) => [entry.gradleAbi, entry]));

export function entryForAbi(abi) {
const entry = BY_ABI.get(abi);
if (!entry) {
throw new Error(
`Unknown Android ABI "${abi}". Expected one of: ${ANDROID_ABI_MATRIX.map((e) => e.abi).join(', ')}`,
);
}
return entry;
}

export function entryForGradleAbi(gradleAbi) {
const entry = BY_GRADLE.get(gradleAbi);
if (!entry) {
throw new Error(`Unknown Gradle ABI "${gradleAbi}"`);
}
return entry;
}

/**
* Parse a comma/space-separated ABI list or the keyword "all".
* Empty / whitespace → defaultAbis.
*/
export function parseAndroidAbis(raw, { defaultAbis = ['aarch64'] } = {}) {
const text = (raw ?? '').trim();
if (!text) {
return defaultAbis.map(entryForAbi);
}
if (text.toLowerCase() === 'all') {
return [...ANDROID_ABI_MATRIX];
}
const tokens = text
.split(/[,\s]+/)
.map((t) => t.trim())
.filter(Boolean);
if (tokens.length === 0) {
return defaultAbis.map(entryForAbi);
}
const seen = new Set();
const out = [];
for (const token of tokens) {
const entry = entryForAbi(token);
if (seen.has(entry.abi)) continue;
seen.add(entry.abi);
out.push(entry);
}
return out;
}

/** GitHub Actions matrix.include payload for selected ABIs. */
export function toGithubMatrixInclude(entries) {
return entries.map((entry) => ({
abi: entry.abi,
rust_target: entry.rustTarget,
gradle_abi: entry.gradleAbi,
output_key: entry.outputKey,
}));
}

function main() {
const mode = process.argv[2] || 'parse';
if (mode === 'parse') {
const raw = process.argv[3] ?? process.env.OPENLESS_ANDROID_ABIS ?? '';
const defaultRaw = process.env.OPENLESS_ANDROID_ABIS_DEFAULT ?? 'aarch64';
const entries = parseAndroidAbis(raw, {
defaultAbis: parseAndroidAbis(defaultRaw, { defaultAbis: ['aarch64'] }).map((e) => e.abi),
});
process.stdout.write(JSON.stringify(toGithubMatrixInclude(entries)));
return;
}
if (mode === 'list-all') {
process.stdout.write(JSON.stringify(toGithubMatrixInclude(ANDROID_ABI_MATRIX)));
return;
}
if (mode === 'rust-targets') {
const raw = process.argv[3] ?? process.env.OPENLESS_ANDROID_ABIS ?? 'all';
const entries = parseAndroidAbis(raw, { defaultAbis: ANDROID_ABI_MATRIX.map((e) => e.abi) });
process.stdout.write(entries.map((e) => e.rustTarget).join(','));
return;
}
console.error(`Usage: node android-abi-matrix.mjs <parse|list-all|rust-targets> [abis]`);
process.exit(1);
}

if (process.argv[1]?.replace(/\\/g, '/').endsWith('android-abi-matrix.mjs')) {
main();
}
Loading
Loading