前言
上一篇讲了 Live Photo Converter 的 Android 构建:pixiewood 怎么组织依赖、GStreamer 为什么要静态链接,以及 APK 如何发布。程序真正跑起来后,首先遇到的却是一个更基础的问题:Android 文件选择器给我的不是文件路径。
SAF(Storage Access Framework)把用户选中的文件交给应用时,使用的是 content:// URI。这种 URI 没有对应的本地路径,应用只能通过 SAF 提供的接口读写。可是 Live Photo Converter 的核心库和 gexiv2 都是按路径工作的,LivePhoto.create (path, dest_dir) 接收的就是路径。最后采用的方案分成两步:先修 GTK Android 后端对 content file 的处理,再在应用里增加一层暂存,把文件复制到本地临时路径,交给原有代码处理,完成后再复制回去。
项目地址仍是 github.com/wszqkzqk/live-photo-conv。本文主要涉及 src/gui.vala、src/sample2img.vala,以及 subprojects/packagefiles/gtk/ 下的补丁。
问题:content:// 没有路径
在桌面上,Gtk.FileDialog 选完文件后调用 file.get_path () 就能得到路径。Android 上,同一个 API 返回的是 GTK Android 后端实现的 content file,也就是 SAF 文档的 GIO 封装。它的 get_path () 返回 null,get_uri () 返回以 content:// 开头的 URI。
这会影响三个地方:
- 核心库无法直接处理:
LivePhoto、LiveMaker和 gexiv2 都需要真实路径; - 输出不能直接写入:用户选择的目录也可能是 content URI,结果要再复制进去;
- 修复模式还要覆盖原文件:修复完成后,暂存副本必须写回原来的 content file。
Linux 桌面上的 GVfs 远程位置(例如 smb://、sftp://)也有同样的问题。从 NAS 拖进来的文件不一定有本地路径。暂存层用 File.is_native () 判断是否需要复制,所以不必把逻辑写死在 Android 分支里。
修复 GTK Android 后端
移植时我在 GTK Android 后端碰到了两个问题。
第一个问题和文件选择器有关。HyperOS 返回的是普通的 MediaStore content URI,而不是 DocumentsContract 定义的 document URI。旧版 GTK 的 from_uri() 不接受这类 URI,选择文件后可能直接崩溃。gtk!10178 扩大了可接受的 URI 范围。这个修复已经合并,所以仓库里对应的补丁已经删掉。
第二个问题出现在写回结果时。把本地暂存文件复制到 content URI,GTK 也可能崩溃。对应修复是 gtk!10190,目前还没有合并,因此项目通过 GTK wrap 临时应用 copy-foreign-source.patch:
@@ gdk/android/gdkandroidcontentfile.c
{
- g_return_val_if_fail (GDK_IS_ANDROID_CONTENT_FILE (file), FALSE);
+ if (!GDK_IS_ANDROID_CONTENT_FILE (file))
+ {
+ g_set_error (error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED, "Cannot copy %s into a content file", G_OBJECT_TYPE_NAME (file));
+ return FALSE;
+ }
if (!GDK_IS_ANDROID_CONTENT_FILE (destination))
{
g_set_error (error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED, "Cannot copy content file into %s", G_OBJECT_TYPE_NAME(destination));
g_file_copy() 会先调用目标文件提供的 copy vfunc。把本地暂存文件复制到 content URI 时,gdk_android_content_file_copy() 收到的源文件当然是普通的本地 GFile。旧代码用 g_return_val_if_fail 直接拒绝它,只返回 FALSE,却没有设置 GError;调用方随后按“错误已经设置”来处理,结果解引用了空指针。
补丁把这种情况改成返回 G_IO_ERROR_NOT_SUPPORTED。GIO 收到这个错误后会退回到流式复制:先读取本地源文件,再通过 content file 创建目标文件。此前的 gtk!9403 已经解决 #7703 中相反方向的复制;gtk!10190 补的正是本地文件写入 content file 这一侧。应用把结果写回 SAF 目录时会走这条路径。
补丁通过 wrap 的 diff_files 引入,GTK 则锁定到包含其他上游修复的 main commit:
# GTK main (pinned; bump manually) plus the copy-foreign-source patch,
# pending upstream merge. Declaring <gtk/> in pixiewood.xml would
# overwrite this wrap.
[wrap-git]
directory = gtk
url = https://gitlab.gnome.org/GNOME/gtk.git
revision = f4781e2bcb14a9bbb57afa50619e28ed326570ed
diff_files = gtk/copy-foreign-source.patch
因此仓库里的 GTK wrap 只保留还没进上游的补丁。等 gtk!10190 合并后,删掉 diff_files 即可恢复使用 GTK 的实现。
将文件暂存到本地路径
GTK 修好后,content file 的 read、write、copy 都能正常工作,但它还是没有本地路径。于是我在 gui.vala 里加了 staging:先复制一份本地临时文件,让按路径工作的库处理这份副本,完成后再把结果复制回用户选中的位置。这部分代码主要由几个小函数组成。
检测与选址
/** TRUE for files without a filesystem path (content://, GVfs remotes). */
private static bool needs_staging (File file) {
return !file.is_native ();
}
判断只看 File.is_native (),不关心 URI scheme,所以 content://、smb://、sftp:// 都能覆盖。这套 staging 代码在所有平台都会编译;0.50 开发期间专门把它改成了平台无关的实现,只有目录位置等少数细节留在 #if ANDROID 中。
暂存目录的位置则因平台而异:
private static string staging_root () {
#if ANDROID
// Relies on the runtime pointing glib's data dirs at <filesDir>/share
unowned var dirs = Environment.get_system_data_dirs ();
var parent = dirs.length > 0 ? Path.get_dirname (dirs[0])
: Environment.get_user_cache_dir ();
return Path.build_filename (parent, "staging");
#else
return Path.build_filename (Environment.get_user_cache_dir (),
"live-photo-conv", "staging");
#endif
}
Android 分支有一个容易忽略的细节。早期代码直接读取 XDG_DATA_DIRS,后来改成调用 glib 的 Environment.get_system_data_dirs ()。pixiewood 运行时会把 glib 的数据目录指向应用私有目录中的 <filesDir>/share,所以程序取它的父目录并在下面创建 staging/。暂存文件始终在应用沙盒里,不需要申请存储权限。桌面端则放在用户缓存目录中。
暂存与回收
/**
* Returns a filesystem path for the file, staging it if needed.
*
* content:// files have no filesystem path; a local copy is staged
* for the path-based library and gexiv2. SAF access must stay on the
* main thread: GTK's content file vfuncs segfault on other threads.
*/
private static string path_for (File file) throws Error {
if (!needs_staging (file)) {
return file.get_path ();
}
var staging_dir = staging_root ();
if (DirUtils.create_with_parents (staging_dir, 0700) != 0) {
throw new FileError.FAILED ("Failed to create staging directory: %s", staging_dir);
}
var local = Path.build_filename (staging_dir, "%s-%s".printf (
Uuid.string_random (), file.get_basename () ?? "unnamed"));
file.copy (File.new_for_path (local), FileCopyFlags.OVERWRITE, null, null);
return local;
}
path_for () 是入口。本地文件直接返回路径,不产生额外开销;content file 则复制到暂存目录,并返回这个副本的路径。文件名前加 UUID,避免同一批次里出现同名文件。输出文件使用 staging_output_path () 生成暂存路径,处理结束后由 cleanup_staged () 删除。
SAF 访问必须在主线程完成
GTK Android 后端的 content file vfunc 最终要通过 JNI 调用 Android 框架。在非主线程调用它会直接段错误。所以暂存复制和结果回写都在主线程完成,后台线程只接触已经转换成本地路径的文件。
这个限制决定了 GUI 的调用顺序。三个操作的入口都在按钮点击处理器或对话框回调中调用 path_for (),而且发生在启动工作线程之前:
private void on_repair_clicked () {
// ……
var paths = new GenericArray<string> ();
try {
foreach (unowned var file in files)
paths.add (path_for (file)); // 主线程:SAF 暂存
} catch (Error e) {
show_error_dialog (_("Error"), e.message);
return;
}
start_work (repair_button, _("Repairing…"));
repair_batch_async.begin (files, paths, force, video_size, repair_button, ...);
}
批处理协程启动后,传给工作线程的已经是纯本地路径数组,线程里不会再碰 content file。处理结束回到主线程,再执行回写。可以把这条边界记成一句话:主线程负责 SAF,工作线程负责本地路径。
三种模式的回写路径
三个操作都会暂存输入,但输出的处理方式不同:
制作:成功后复制到目标位置
制作模式的输出由保存对话框决定。如果目标是 content file,先写入暂存路径,制作成功后再复制到用户选择的位置:
bool output_staged = needs_staging (output_file);
video_path = path_for (video_file);
image_path = image_file != null ? path_for (image_file) : null;
// A staged output is copied back to the picked destination on success
output_path = output_staged
? staging_output_path (output_file.get_basename () ?? "live-photo.jpg")
: output_file.get_path ();
maker = LiveMaker.create (video_path, image_path, output_path);
// ……异步制作完成后:
if (output_staged) {
File.new_for_path (output_path).copy (
output_file, FileCopyFlags.OVERWRITE, null, null);
}
无论成功还是失败,回调都会清理视频、主图和输出的暂存文件。
提取:暂存整个目录,再逐个复制
提取支持批量处理,输出目标是一个目录。SAF 选中的目录同样没有路径,因此先把整个批次输出到 UUID 命名的暂存目录,完成后再逐个复制到 SAF 目录:
// SAF-picked folders have no path: extract into a staging dir,
// then copy the results into the picked folder
string? dest_dir = dest_folder.get_path ();
File? copy_out_folder = null;
if (needs_staging (dest_folder)) {
dest_dir = Path.build_filename (staging_root (), Uuid.string_random ());
// ……
copy_out_folder = dest_folder;
}
回写时每个文件单独处理复制错误。即使其中一个文件失败,也继续复制剩余文件,最后以 %u of %u files failed 汇总结果:
foreach (unowned var name in names.data) {
// One failed copy must not strand the rest of the batch
var child = staging.get_child (name);
try {
child.copy (copy_out_folder.get_child (name),
FileCopyFlags.OVERWRITE, null, null);
child.delete ();
} catch (Error e) {
// 记入错误汇总,继续下一个
}
}
修复:覆盖原文件与非原子写回
修复模式是原位操作,回写时要把修好的暂存副本覆盖回原始的 content:// 文件:
// Repair works in place: write the staging copies back over the
// original content:// files (not atomic: a crash mid-copy can
// corrupt the original)
for (int i = 0; i < files.length; i += 1) {
if (succeeded[i] && needs_staging (files[i])) {
try {
File.new_for_path (paths[i]).copy (
files[i], FileCopyFlags.OVERWRITE, null, null);
} catch (Error e) {
// 记入错误汇总
}
}
if (needs_staging (files[i]))
cleanup_staged (paths[i]);
}
只有修复成功(succeeded[i])的文件才会写回,失败的原文件不会动。这次复制也不是原子操作:如果中途崩溃,原文件可能只写了一部分。SAF 没有可用的 rename 语义,暂时无法用“写临时文件再替换”的方式规避这个问题。
崩溃恢复:启动时清理残留
正常流程结束时会清理暂存文件,但应用可能在处理中途被系统杀掉,留下没有机会清理的文件。为此,程序每次启动时都会再清理一次:
public override void startup () {
base.startup ();
clear_staging ();
// ……
}
/** Wipes leftover staging files from a previous run. */
private static void clear_staging () {
delete_recursively (File.new_for_path (staging_root ()));
}
clear_staging () 会递归删除整个暂存根目录。它在 startup () 中调用,早于窗口创建和文件操作,所以上次运行留下的内容会在新任务开始前消失。暂存区位于应用沙盒中,用户看不到。
gdk-pixbuf 的 Android saver
除了 staging,Android 的图像保存也踩到过一个坑。gdk-pixbuf 的 Android saver 会把像素缓冲区按 RGBA_8888 交给 AndroidBitmap_compress(),所以没有 alpha 通道的 pixbuf 保存必定失败,返回 ANDROID_BITMAP_RESULT_BAD_PARAMETER。视频采样得到的帧通常没有 alpha,早期版本提取视频帧时会因此悄悄失败:界面显示成功,磁盘上却没有文件。
修复集中在一个小函数里(src/sample2img.vala):
internal Gdk.Pixbuf pixbuf_with_opaque_alpha (Gdk.Pixbuf pixbuf) {
#if ANDROID
return pixbuf.has_alpha ? pixbuf : pixbuf.add_alpha (false, 0, 0, 0);
#else
return pixbuf;
#endif
}
在 Android 上,无 alpha 的 pixbuf 会补上一条不透明 alpha 通道。Android loader 解码出来的结果本来就是 RGBA,其他平台的 saver 也接受 RGB,因此非 Android 分支不需要做任何处理。运行时代码中的 #if ANDROID 目前只有两处:staging_root () 的目录选择,以及这个 alpha 修复。
结语
这次 SAF 适配最后落在三个点上:修好 GTK 的 content file 复制、用 staging 把没有路径的文件转换成本地副本,以及把所有 SAF 操作限制在主线程。核心库不需要知道 Android 的 content://,它始终只接收本地路径。
构建和运行时的 Android 适配就先写到这里。0.40 到 0.50 期间还做了并发背压、内存泄漏、修复算法和边界检查等与平台无关的改动,之后再单独整理。
赞赏本文
| 支付宝 | 微信支付 |
|---|---|
![]() |
![]() |

