Fyne ( go跨平台GUI )项目实战-项目开发必备基础知识(下)

Fyne ( go跨平台GUI )项目实战-项目开发必备基础知识(下)

18. 移动端适配 (Android)

18.1 平台差距处理

go 复制代码
// 来源: OnlineExamApp/utils/sqlite.go
func GetConnection() (*sql.DB, error) {
    sysType := GetGoos()
    if sysType == "windows" {
        dbName = "./data.db3"     // Windows 使用当前目录
    }
    if sysType == "android" {
        // Android 使用应用私有存储路径
        dbName = "/data/data/com.example.onlineexamapp/files/data.db3"
    }
    db, err = sql.Open(dbDriverName, dbName)
    return db, err
}

18.2 Android 打包配置

Fyne 项目打包为 Android APK 的方法:

bash 复制代码
# 安装 Android SDK 和 NDK(前提条件)
# 使用 fyne 命令行工具打包
fyne package -os android -appID com.example.myapp -icon icon.png

# 生成 .apk 文件
fyne install -os android

18.3 设备与 UI 线程

go 复制代码
// 来源: fyne demo v2.8.0 --- tutorials/welcome.go, advanced.go
import "fyne.io/fyne/v2"

// 设备类型检测
if fyne.CurrentDevice().IsMobile() {
    // 移动端适配布局
} else if fyne.CurrentDevice().IsBrowser() {
    // WebAssembly 环境适配
} else {
    // 桌面端默认行为
}

// fyne.Do --- 安全地在 UI 线程执行函数(常用于 goroutine 中更新 UI)
fyne.Do(func() {
    // 此代码保证在 UI 线程执行
    // 适用于从非 UI 线程(goroutine、网络回调)安全更新 Fyne 控件
    statusLabel.SetText("操作完成")
})

说明

  • IsMobile() 返回 true 表示 Android/iOS 移动设备。
  • IsBrowser() 返回 true 表示 WebAssembly 运行环境。
  • fyne.Do() 类似 Android 的 runOnUiThread,保证回调在 UI 线程执行。

19. CGO 与原生平台集成

CGO 是 Go 调用 C 代码的桥梁。当 Fyne 项目需要直接访问 Android 的 NDK 摄像头、JNI WebView 或 Windows 的 Win32 API 等底层能力时,CGO 是唯一的解决方案。本章所有示例代码来自 FyneCameraFyneWebView 两个实战项目。

19.1 CGO 基础概念

19.1.1 C 代码嵌入与编译指令
go 复制代码
// 来源: FyneCamera/scanner/camera_android.go(CGO 声明区)
// 来源: FyneWebView/webview/webview_android.go(CGO 声明区)

// build tag:仅在 android 平台且非 386 架构编译
//go:build android && !386

package scanner

/*
// #cgo 指令:链接 Android NDK 原库
#cgo LDFLAGS: -lcamera2ndk -lmediandk -llog -landroid

// gomobile 默认将 __ANDROID_API__ 设为较低值(如 16),
// 这会隐藏 API 24+ 才引入的 NDK Camera2 符号。
// 这里强制覆盖为 24,使头文件暴露 Camera2 函数声明。
#undef __ANDROID_API__
#define __ANDROID_API__ 24

// 引入 NDK 头文件
#include <android/log.h>
#include <camera/NdkCameraManager.h>
#include <camera/NdkCameraDevice.h>
#include <camera/NdkCameraMetadata.h>
#include <camera/NdkCameraCaptureSession.h>
#include <media/NdkImageReader.h>
#include <media/NdkImage.h>
#include <jni.h>

// Android logcat 日志宏
#define LOG_TAG "fyne_camera"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO,  LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

// Go 可调用的辅助日志函数
static void goLog(const char* msg) {
    __android_log_print(ANDROID_LOG_INFO, LOG_TAG, "%s", msg);
}

// 相机上下文结构体 ------ 管理 NDK Camera2 资源的 C 端全局状态
typedef struct {
    ACameraManager*  manager;        // 相机管理器
    ACameraDevice*   device;         // 相机设备
    AImageReader*    imageReader;    // 图像读取器(YUV_420_888 格式)
    ACameraCaptureSession* session;  // 捕获会话
    ANativeWindow*   window;         // 原生窗口表面
    ACaptureRequest* request;        // 捕获请求
    int width;
    int height;
    int camera_opened;
    int session_ready;
    int closing;                     // 关闭标志,防止回调访问已释放的资源
    int sensor_orientation;          // 传感器旋转角度

    // 帧缓冲 ------ 线程安全
    pthread_mutex_t  lock;
    pthread_cond_t   cond;
    uint8_t*         frame_data;     // 最新帧的 Y 平面数据
    int              frame_len;
    int              frame_width;
    int              frame_height;
    int              has_frame;
} CameraCtx;
*/
import "C" // 必须紧跟 C 注释块,中间不能有空行

关键规则

  • import "C" 必须紧跟在 */ 之后,中间不能有空行或其它代码。
  • #cgo LDFLAGS: -l库名 --- 链接 native 动态库(不含 lib 前缀和 .so 后缀)。
  • #cgo CFLAGS: -I/路径 --- 添加头文件搜索路径。
  • __ANDROID_API__ 覆盖是访问 API 24+ NDK 符号的关键技巧。
  • build tag (//go:build android) 确保非 Android 平台不执行 CGO 编译。
  • C.struct_xxx 引用 C 端定义的结构体。
19.1.2 C.CString / C.free 内存管理模式
go 复制代码
// 来源: FyneCamera/scanner/camera_android.go(Go 层封装)

import "unsafe"

// CheckPermission 检查指定权限是否已授予
func CheckPermission(permission string) bool {
    // C.CString 将 Go string 转为 C 字符串(在 C 堆上分配)
    cPerm := C.CString(permission)
    // 使用完毕后必须调用 C.free 释放,否则内存泄漏
    defer C.free(unsafe.Pointer(cPerm))
    return C.check_permission(cPerm) == permGranted
}

// RequestPermission 请求指定权限
func RequestPermission(permission string) {
    cPerm := C.CString(permission)
    defer C.free(unsafe.Pointer(cPerm))
    C.request_permission(cPerm)
}

// Log 输出日志到 Android logcat
func Log(msg string) {
    cMsg := C.CString(msg)
    defer C.free(unsafe.Pointer(cMsg))
    C.goLog(cMsg) // 调用 C 端 static 函数
}

内存管理规则

  • C.CString(s) --- 在 C 堆上分配内存,调用者负责释放。
  • C.free(unsafe.Pointer(cs)) --- 释放 C.CString 分配的内存。
  • C.GoBytes(ptr, len) --- 将 C 字节数组复制为 Go []byte(托管内存,无需手动释放)。
  • C 端 malloc 的数据在 Go 端必须调用 C.free() 释放。
19.1.3 数据类型映射速查
Go 类型 C 类型 用途
C.int int 整型传递
C.uint8_t uint8_t 字节/像素数据
*C.uint8_t uint8_t* 字节数组指针
*C.char char* C 字符串指针
unsafe.Pointer void* 通用指针转换
C.JNIEnv JNIEnv* JNI 环境指针
C.jobject jobject JNI 对象引用
*C.JavaVM JavaVM* Java 虚拟机指针

19.2 NDK Camera2 摄像头集成

FyneCamera 项目通过 CGO 直接调用 Android NDK Camera2 API,实现了无需 Java 层的纯 C/C++ 摄像头控制,用于二维码扫描。

19.2.1 完整管道架构
scss 复制代码
ACameraManager → ACameraDevice → AImageReader → ACaptureSession → 帧回调
                                   ↓ (YUV_420_888)
                               on_image_available()
                                   ↓
                              g_ctx.frame_data (Y 平面灰度数据)
                                   ↓ 
                              Go: pollFrame() → Frame 结构体
19.2.2 相机初始化
c 复制代码
// 来源: FyneCamera/scanner/camera_android.go(函数 camera_init)

// 流程:选择后置摄像头 → 打开设备 → 创建 ImageReader → 
//       创建 CaptureSession → 设置重复请求 → 等待会话就绪
static int camera_init(int width, int height) {
    pthread_mutex_init(&g_ctx.lock, NULL);
    pthread_cond_init(&g_ctx.cond, NULL);
    g_ctx.width  = width;
    g_ctx.height = height;

    // 1. 创建相机管理器
    g_ctx.manager = ACameraManager_create();

    // 2. 选择后置摄像头(根据 LENS_FACING_BACK 筛选)
    char* cam_id = NULL;
    int orientation = 0;
    select_back_camera_id(&cam_id, &orientation);
    g_ctx.sensor_orientation = orientation;

    // 3. 打开相机设备
    ACameraDevice_StateCallbacks device_cbs = {
        .context       = NULL,
        .onDisconnected = on_device_disconnected,  // 断开回调
        .onError        = on_device_error,          // 错误回调
    };
    ACameraManager_openCamera(g_ctx.manager, cam_id, &device_cbs, &g_ctx.device);
    free(cam_id);
    g_ctx.camera_opened = 1;
    
    // 4. 创建 ImageReader(YUV_420_888 格式,最多缓存 4 帧)
    AImageReader_new(width, height, AIMAGE_FORMAT_YUV_420_888, 4, &g_ctx.imageReader);

    // 5. 设置图像可用回调
    AImageReader_ImageListener listener = {
        .context         = NULL,
        .onImageAvailable = on_image_available,  // 核心回调
    };
    AImageReader_setImageListener(g_ctx.imageReader, &listener);

    // 6. 获取 ImageReader 的 ANativeWindow 作为输出目标
    AImageReader_getWindow(g_ctx.imageReader, &g_ctx.window);

    // 7. 创建捕获会话输出容器,添加 target
    ACaptureSessionOutputContainer_create(&outputs);
    ACaptureSessionOutput_create(g_ctx.window, &session_output);
    ACaptureSessionOutputContainer_add(outputs, session_output);

    // 8. 创建捕获会话
    ACameraCaptureSession_stateCallbacks session_cbs = {
        .onActive = on_session_active,  // 会话激活 = 可以接收帧
    };
    ACameraDevice_createCaptureSession(g_ctx.device, outputs, &session_cbs, &g_ctx.session);

    // 9. 创建捕获请求(TEMPLATE_PREVIEW = 预览模式)
    ACameraDevice_createCaptureRequest(g_ctx.device, TEMPLATE_PREVIEW, &g_ctx.request);
    ACameraOutputTarget_create(g_ctx.window, &target);
    ACaptureRequest_addTarget(g_ctx.request, target);

    // 10. 启动连续捕获(setRepeatingRequest)
    ACameraCaptureSession_setRepeatingRequest(g_ctx.session, NULL, 1, &g_ctx.request, NULL);

    // 11. 等待会话激活信号 (条件变量同步)
    pthread_mutex_lock(&g_ctx.lock);
    while (!g_ctx.session_ready) {
        pthread_cond_wait(&g_ctx.cond, &g_ctx.lock);
    }
    pthread_mutex_unlock(&g_ctx.lock);

    return 0;
}
19.2.3 帧回调与图像捕获
c 复制代码
// 来源: FyneCamera/scanner/camera_android.go(函数 on_image_available)

// 当摄像头产生新帧时,ImageReader 在独立线程中回调此函数
static void on_image_available(void* context, AImageReader* reader) {
    // 检查关闭标志,避免关闭后访问已释放资源
    pthread_mutex_lock(&g_ctx.lock);
    if (g_ctx.closing) {
        pthread_mutex_unlock(&g_ctx.lock);
        return;
    }
    pthread_mutex_unlock(&g_ctx.lock);

    // 获取最新的图像
    AImage* image = NULL;
    AImageReader_acquireLatestImage(reader, &image);
    
    int32_t w = 0, h = 0;
    AImage_getWidth(image, &w);
    AImage_getHeight(image, &h);

    // 只提取 Y 平面(亮度通道)------ 用于二维码解码的灰度数据
    uint8_t* y_data = NULL;
    int      y_len  = 0;
    AImage_getPlaneData(image, 0, &y_data, &y_len);

    // 线程安全地存储帧数据到全局缓冲区
    pthread_mutex_lock(&g_ctx.lock);
    if (!g_ctx.closing) {
        if (g_ctx.frame_data) free(g_ctx.frame_data);
        g_ctx.frame_data   = (uint8_t*)malloc(y_len);
        memcpy(g_ctx.frame_data, y_data, y_len);
        g_ctx.frame_len    = y_len;
        g_ctx.frame_width  = w;
        g_ctx.frame_height = h;
        g_ctx.has_frame    = 1;
    }
    pthread_mutex_unlock(&g_ctx.lock);

    AImage_delete(image);
}

// 从 Go 层轮询帧数据
static int camera_get_frame(uint8_t** out_data, int* out_len, int* out_w, int* out_h) {
    pthread_mutex_lock(&g_ctx.lock);
    int has = g_ctx.has_frame;
    if (has && g_ctx.frame_data && g_ctx.frame_len > 0) {
        // 在 C 堆上分配新缓冲区,避免数据被后续帧覆盖
        *out_data = (uint8_t*)malloc(g_ctx.frame_len);
        memcpy(*out_data, g_ctx.frame_data, g_ctx.frame_len);
        *out_len = g_ctx.frame_len;
        *out_w   = g_ctx.frame_width;
        *out_h   = g_ctx.frame_height;
        g_ctx.has_frame = 0;  // 标记为已消费
    }
    pthread_mutex_unlock(&g_ctx.lock);
    return has;  // 返回 0 表示没有新帧
}
19.2.4 Go 层封装
go 复制代码
// 来源: FyneCamera/scanner/camera_android.go(Go 层)

// androidCamera NDK Camera2 的 Go 封装
type androidCamera struct {
    width, height int
}

// openCamera 打开后置摄像头
func openCamera(width, height int) (*androidCamera, error) {
    if rc := C.camera_init(C.int(width), C.int(height)); rc != 0 {
        return nil, errors.New("camera_init failed")
    }
    return &androidCamera{width: width, height: height}, nil
}

// pollFrame 轮询最新帧,无新帧返回 nil
func (c *androidCamera) pollFrame() *Frame {
    var data *C.uint8_t
    var length, w, h C.int
    if C.camera_get_frame(&data, &length, &w, &h) == 0 {
        return nil
    }
    orientation := int(C.camera_get_orientation())
    frame := &Frame{
        Width:      int(w),
        Height:     int(h),
        Data:       C.GoBytes(unsafe.Pointer(data), length), // C → Go 字节切片
        Format:     FormatGray,
        Orientation: orientation,
    }
    C.free(unsafe.Pointer(data)) // 释放 C 端 malloc 的内存
    return frame
}

// close 关闭摄像头并释放所有 NDK 资源
func (c *androidCamera) close() {
    C.camera_close()
}

资源释放顺序camera_close 的关键设计):

  1. 先设置 closing = 1 标志,阻止新帧回调写入
  2. 停止并关闭 CaptureSession --- 停止触发新的 ImageReader 回调
  3. usleep(200000) --- 等待 200ms 让正在执行的 binder 回调完成
  4. 释放帧数据缓冲区
  5. 释放 CaptureRequest
  6. 关闭 CameraDevice(必须在 ImageReader 删除前)
  7. 删除 AImageReader(会自动释放 ANativeWindow,不要单独调用 ANativeWindow_release)
  8. 删除 CameraManager
  9. 销毁 pthread 同步原语 注意: AImageReader_delete 内部会释放 ANativeWindow,单独调用 ANativeWindow_release 会导致 double-free 崩溃。

19.3 JNI 运行时权限系统

FyneCamera 项目在纯 C/NDK 环境中,通过 dlsym 动态获取 JNI 环境,实现对 Android 运行时权限的检查和请求。

19.3.1 动态获取 JNI 环境
c 复制代码
// 来源: FyneCamera/scanner/camera_android.go(JNI 环境获取)

// 类型别名:JNI_GetCreatedJavaVMs 函数指针
typedef jint (*JNI_GetCreatedJavaVMs_t)(JavaVM**, jsize, jsize*);

// 尝试从已加载的共享库中查找 JNI_GetCreatedJavaVMs 符号
static JNI_GetCreatedJavaVMs_t load_get_created_java_vms(void) {
    // 1. 先从全局符号表中查找(RTLD_DEFAULT)
    JNI_GetCreatedJavaVMs_t fn = (JNI_GetCreatedJavaVMs_t)dlsym(RTLD_DEFAULT, "JNI_GetCreatedJavaVMs");
    if (fn) return fn;

    // 2. 如果失败,逐个尝试已知的 JNI 宿主库
    const char* libs[] = {
        "libnativehelper.so",  // 新版 Android
        "libart.so",           // ART 运行时
        "libartd.so",          // ART 调试版
        "libjvm.so",           // 旧版/兼容
        NULL
    };
    for (int i = 0; libs[i]; i++) {
        void* handle = dlopen(libs[i], RTLD_NOW);
        if (handle) {
            fn = (JNI_GetCreatedJavaVMs_t)dlsym(handle, "JNI_GetCreatedJavaVMs");
            if (fn) return fn;
        }
    }
    return NULL;
}

// 获取当前线程的 JNIEnv 指针
// 如果线程未附着到 JVM,自动调用 AttachCurrentThread
static JNIEnv* get_jni_env(void) {
    static JNI_GetCreatedJavaVMs_t get_vms = NULL;
    if (!get_vms) {
        get_vms = load_get_created_java_vms();
        if (!get_vms) return NULL;
    }

    // 获取 JavaVM
    JavaVM* vm = NULL;
    jsize count = 0;
    jint rc = get_vms(&vm, 1, &count);
    if (rc != JNI_OK || count == 0 || !vm) return NULL;

    // 获取当前线程的 JNIEnv
    JNIEnv* env = NULL;
    jint attach_result = (*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6);
    if (attach_result == JNI_EDETACHED) {
        // 如果未附着,先附着到 JVM
        attach_result = (*vm)->AttachCurrentThread(vm, &env, NULL);
    }
    return env;
}

核心设计

  • 不依赖 Java 初始化传递 JNIEnv ,而是通过 dlsym 在运行时动态查找 JVM 入口。
  • 兼容多种 Android 版本:依次查找 libnativehelper.so, libart.so 等。
  • 自动处理 EDETACHED 状态(C 线程未附着到 JVM 时自动附着)。
19.3.2 运行时权限检查
c 复制代码
// 来源: FyneCamera/scanner/camera_android.go(check_permission)

// 通过 ApplicationContext 调用 checkSelfPermission
static int check_permission(const char* permission) {
    JNIEnv* env = get_jni_env();
    if (!env) return -1;

    // 获取 Application Context
    jclass activity_thread = (*env)->FindClass(env, "android/app/ActivityThread");
    jmethodID current_app = (*env)->GetStaticMethodID(env, activity_thread,
        "currentApplication", "()Landroid/app/Application;");
    jobject app = (*env)->CallStaticObjectMethod(env, activity_thread, current_app);

    // 调用 checkSelfPermission
    jclass context_clazz = (*env)->FindClass(env, "android/content/Context");
    jmethodID check_perm_mid = (*env)->GetMethodID(env, context_clazz,
        "checkSelfPermission", "(Ljava/lang/String;)I");
    jstring perm_str = (*env)->NewStringUTF(env, permission);
    jint result = (*env)->CallIntMethod(env, app, check_perm_mid, perm_str);

    // 清理 JNI 本地引用
    (*env)->DeleteLocalRef(env, perm_str);
    return result; // 0=PACKAGE_PERMISSION_GRANTED, -1=PERMISSION_DENIED
}
19.3.3 运行时权限请求(反射 ActivityThread)
c 复制代码
// 来源: FyneCamera/scanner/camera_android.go(request_permission)

// 反射获取当前 Activity(通过 ActivityThread.mActivities)
static jobject get_current_activity(JNIEnv* env) {
    jclass at_cls = (*env)->FindClass(env, "android/app/ActivityThread");
    jmethodID cat = (*env)->GetStaticMethodID(env, at_cls,
        "currentActivityThread", "()Landroid/app/ActivityThread;");
    jobject at = (*env)->CallStaticObjectMethod(env, at_cls, cat);

    // 反射读取 mActivities 字段(ArrayMap 存储所有 Activity)
    jfieldID maf = (*env)->GetFieldID(env, at_cls, "mActivities", "Landroid/util/ArrayMap;");
    jobject acts = (*env)->GetObjectField(env, at, maf);

    // 遍历 ArrayMap 获取第一个 Activity
    jclass am_cls = (*env)->FindClass(env, "android/util/ArrayMap");
    jmethodID vals = (*env)->GetMethodID(env, am_cls, "values", "()Ljava/util/Collection;");
    jobject col = (*env)->CallObjectMethod(env, acts, vals);

    jclass col_cls = (*env)->FindClass(env, "java/util/Collection");
    jmethodID iter_mid = (*env)->GetMethodID(env, col_cls, "iterator", "()Ljava/util/Iterator;");
    jobject iter = (*env)->CallObjectMethod(env, col, iter_mid);

    jclass it_cls = (*env)->FindClass(env, "java/util/Iterator");
    jmethodID hasNext = (*env)->GetMethodID(env, it_cls, "hasNext", "()Z");
    jmethodID next = (*env)->GetMethodID(env, it_cls, "next", "()Ljava/lang/Object;");

    // 迭代查找 ActivityClientRecord,提取 activity 字段
    jobject activity = NULL;
    while ((*env)->CallBooleanMethod(env, iter, hasNext)) {
        jobject rec = (*env)->CallObjectMethod(env, iter, next);
        jclass rec_cls = (*env)->FindClass(env, "android/app/ActivityThread$ActivityClientRecord");
        jfieldID act_f = (*env)->GetFieldID(env, rec_cls, "activity", "Landroid/app/Activity;");
        if (act_f) {
            activity = (*env)->GetObjectField(env, rec, act_f);
        }
        if (activity) break;
    }
    return activity;
}

// 调用 Activity.requestPermissions() 弹出系统权限对话框
static void request_permission(const char* permission) {
    JNIEnv* env = get_jni_env();
    jobject activity = get_current_activity(env);

    jclass activity_clazz = (*env)->FindClass(env, "android/app/Activity");
    jmethodID request_perm_mid = (*env)->GetMethodID(env, activity_clazz,
        "requestPermissions", "([Ljava/lang/String;I)V");

    // 构造 String[] 参数数组
    jobjectArray perms = (*env)->NewObjectArray(env, 1,
        (*env)->FindClass(env, "java/lang/String"), NULL);
    jstring perm_str = (*env)->NewStringUTF(env, permission);
    (*env)->SetObjectArrayElement(env, perms, 0, perm_str);

    (*env)->CallVoidMethod(env, activity, request_perm_mid, perms, 1001);
}

设计要点

  • 使用 Android 内部 API (ActivityThread, ArrayMap) 反射获取 Activity --- 这在生产应用中需谨慎(内部 API 可能随版本变化)。
  • 所有 JNI 本地引用 (NewLocalRef) 必须调用 DeleteLocalRef 释放,避免超出 JNI 引用表上限。
  • 权限检查使用 ApplicationContext.checkSelfPermission(),不需要 Activity。
  • 权限请求必须通过 Activity.requestPermissions(),因为需要弹出系统 UI 对话框。

19.4 Android WebView JNI 完整集成

FyneWebView 项目通过 CGO + JNI 在 Fyne 的 GLSurfaceView 之上叠加一个 Android 原生 WebView,解决了 Fyne 无法直接渲染网页内容的问题。

19.4.1 架构概览
scss 复制代码
                       ┌─────────────────────────┐
                       │    Go goroutine         │
                       │  loadURL / loadHTML /   │
                       │  eval / setBounds /     │
                       │  setVisible / destroy   │
                       └───────────┬─────────────┘
                                   │ C 函数调用
                       ┌───────────▼─────────────┐
                       │   C 层 (enqueue_task)   │
                       │   线程安全任务队列        │
                       │   + 合并 bounds 槽位      │
                       └───────────┬─────────────┘
                                   │ post_runnable()
                                   │ Handler.post()
                       ┌───────────▼─────────────┐
                       │ Android 主线程            │
                       │ native_run() 执行任务     │
                       │   ├── do_load_url        │
                       │   ├── do_load_html       │
                       │   ├── do_eval            │
                       │   ├── do_set_bounds      │
                       │   ├── do_set_visible     │
                       │   ├── do_clear_focus     │
                       │   ├── do_request_focus   │
                       │   └── do_destroy         │
                       └─────────────────────────┘
19.4.2 全局状态与线程安全
c 复制代码
// 来源: FyneWebView/webview/webview_android.go(全局状态管理)

// ─── 全局状态(全部为 GlobalRef) ─────────────────────────
static JavaVM*   g_vm            = NULL;  // Java 虚拟机
static jobject   g_activity       = NULL;  // Android Activity
static jobject   g_webview        = NULL;  // WebView 实例
static jobject   g_rootView       = NULL;  // content FrameLayout
static jobject   g_window_manager = NULL;  // WindowManager
static jobject   g_wm_params      = NULL;  // WindowManager.LayoutParams(复用)
static jobject   g_handler        = NULL;  // 主线程 Handler
static jclass    g_nr_class       = NULL;  // NativeRunnable 类引用
static int       g_nr_registered  = 0;     // 是否已注册 native 方法
static jclass    g_ftl_class       = NULL; // FocusRestoreTouchListener
static int       g_focus_restored = 0;     // 是否已恢复焦点

// ─── 线程附着辅助函数 ────────────────────────────────────
static JNIEnv* attach_thread(int* was_attached) {
    JNIEnv* env = NULL;
    *was_attached = 0;
    if (!g_vm) return NULL;
    // 已附着的线程直接返回 JNIEnv
    if ((*g_vm)->GetEnv(g_vm, (void**)&env, JNI_VERSION_1_6) == JNI_OK) {
        return env;
    }
    // 未附着的线程需要显式 attach
    JavaVMAttachArgs args;
    args.version = JNI_VERSION_1_6;
    args.name    = "FyneWebView";
    args.group   = NULL;
    if ((*g_vm)->AttachCurrentThread(g_vm, &env, &args) != JNI_OK) {
        return NULL;
    }
    *was_attached = 1;
    return env;
}

static void detach_thread(int was_attached) {
    if (was_attached) {
        (*g_vm)->DetachCurrentThread(g_vm);
    }
}

JNI 对象管理要点

  • NewGlobalRef() --- 创建全局引用,跨线程/跨 JNI 调用保持不变,必须手动 DeleteGlobalRef() 释放。
  • NewLocalRef() / GetObjectClass() 返回的是本地引用,方法返回后自动失效,但需在函数内显式 DeleteLocalRef() 避免栈溢出。
  • 不要在 native_run() 等频繁调用的方法中累积未释放的本地引用 --- 每个 JNI 方法调用有本地引用数量上限(通常 512 个)。
19.4.3 WindowManager 覆盖层创建
c 复制代码
// 来源: FyneWebView/webview/webview_android.go(webview_create_on_main)

// Fyne 渲染在 GLSurfaceView 上,其硬件 Surface 层位于普通 View 层次之上。
// 直接将 WebView 添加到 android.R.id.content 会不可见(被 GL 层遮挡)。
// 解决方案:使用 WindowManager.addView() 创建一个独立的子窗口,
// 类型为 TYPE_APPLICATION_ABOVE_SUB_PANEL (1005),位于 GLSurfaceView 之上。

// ── 获取 WindowManager ──────────────────────────────────
jclass actCls = (*env)->GetObjectClass(env, g_activity);
jmethodID gSys = (*env)->GetMethodID(env, actCls,
    "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;");
jstring wsName = (*env)->NewStringUTF(env, "window");
jobject wm = (*env)->CallObjectMethod(env, g_activity, gSys, wsName);
g_window_manager = (*env)->NewGlobalRef(env, wm);  // 全局引用保存

// ── 创建 WindowManager.LayoutParams ─────────────────────
// 参数: (width, height, type, flags, format)
//   type  = 1005 (TYPE_APPLICATION_ABOVE_SUB_PANEL) --- 最高级子窗口
//   flags = 32 | 0x01000000  (NOT_TOUCH_MODAL | HARDWARE_ACCELERATED)
//          不设置 FLAG_NOT_FOCUSABLE,让 WebView 能接收 IME
//   format = -3 (PixelFormat.TRANSLUCENT) --- 半透明像素格式
jclass wmlpCls = (*env)->FindClass(env, "android/view/WindowManager$LayoutParams");
jmethodID wmlpCtor = (*env)->GetMethodID(env, wmlpCls, "<init>", "(IIIII)V");
jobject params = (*env)->NewObject(env, wmlpCls, wmlpCtor, w, h, 1005, 32 | 0x01000000, -3);

// 设置 gravity 和位置
jfieldID fGrav = (*env)->GetFieldID(env, wmlpCls, "gravity", "I");
(*env)->SetIntField(env, params, fGrav, 0x33);  // TOP | LEFT
jfieldID fX = (*env)->GetFieldID(env, wmlpCls, "x", "I");
jfieldID fY = (*env)->GetFieldID(env, wmlpCls, "y", "I");
(*env)->SetIntField(env, params, fX, x);
(*env)->SetIntField(env, params, fY, y);

g_wm_params = (*env)->NewGlobalRef(env, params);  // 保存为全局引用

// ── 将 WebView 添加到 WindowManager ────────────────────
jclass wmCls = (*env)->GetObjectClass(env, g_window_manager);
jmethodID addView = (*env)->GetMethodID(env, wmCls,
    "addView", "(Landroid/view/View;Landroid/view/ViewGroup$LayoutParams;)V");
(*env)->CallVoidMethod(env, g_window_manager, addView, g_webview, g_wm_params);

关键设计

  • TYPE_APPLICATION_ABOVE_SUB_PANEL (1005) 是最高优先级的子窗口类型,确保 WebView 渲染在 GLSurfaceView 之上。
  • FLAG_NOT_TOUCH_MODAL 确保触摸事件不独占,Fyne 界面依然可以接收触摸。
  • FLAG_HARDWARE_ACCELERATED 启用 WebView 的硬件加速渲染。
  • PixelFormat.TRANSLUCENT (-3) 允许 WebView 背景透明,与 Fyne 界面融合。
19.4.4 异步任务队列
c 复制代码
// 来源: FyneWebView/webview/webview_android.go(任务队列系统)

// 除初始化外,所有 WebView 操作都是 fire-and-forget 模式:
// Go goroutine 将任务入队后立即返回,Android 主线程空闲时通过 Handler.post() 处理。

typedef struct task_node {
    int   op;         // 操作码: 1=loadURL 2=loadHTML 3=eval
                      //        4=setBounds(合并槽) 5=setVisible 6=destroy
                      //        7=clearFocus 8=requestFocus
    char* str;        // strdup 拷贝的字符串,在 native_run 中 free
    int   x, y, w, h; // setBounds 参数
    int   visible;    // setVisible 参数
    struct task_node* next;
} task_node_t;

static task_node_t*    g_q_head  = NULL;  // 队列头
static task_node_t*    g_q_tail  = NULL;  // 队列尾
static pthread_mutex_t g_q_mutex = PTHREAD_MUTEX_INITIALIZER;  // 队列锁

// 入队(从任意 goroutine 调用)
static void enqueue_task(task_node_t* node) {
    pthread_mutex_lock(&g_q_mutex);
    if (g_q_tail) {
        g_q_tail->next = node;
    } else {
        g_q_head = node;
    }
    g_q_tail = node;
    pthread_mutex_unlock(&g_q_mutex);
}

// 出队(从主线程的 native_run 调用)
static task_node_t* dequeue_task(void) {
    pthread_mutex_lock(&g_q_mutex);
    task_node_t* node = g_q_head;
    if (g_q_head) {
        g_q_head = g_q_head->next;
        if (!g_q_head) g_q_tail = NULL;
    }
    pthread_mutex_unlock(&g_q_mutex);
    return node;
}

// native_run 在主线程执行:排空队列 + 应用合并后的 bounds
static void native_run(JNIEnv* env, jobject thiz) {
    // 1) 检查初始化同步任务
    if (g_init_task) {
        webview_create_on_main(env, g_init_task->x, ...);
        g_init_task->done = 1;
        return;
    }

    // 2) 排空整个任务队列
    task_node_t* node;
    while ((node = dequeue_task()) != NULL) {
        switch (node->op) {
            case 1: do_load_url  (env, node->str); break;
            case 2: do_load_html (env, node->str); break;
            case 3: do_eval      (env, node->str); break;
            case 5: do_set_visible(env, node->visible); break;
            case 6: do_destroy    (env); break;
            case 7: do_clear_focus  (env); break;
            case 8: do_request_focus(env); break;
        }
        if (node->str) free(node->str);
        free(node);
    }

    // 3) 应用合并后的 bounds(如果 pending)
    if (g_bounds.pending) {
        do_set_bounds(env, g_bounds.x, g_bounds.y, g_bounds.w, g_bounds.h);
        g_bounds.pending = 0;
    }
}

// ── Fire-and-forget 公共 API ──────────────────────────
void webview_load_url(const char* url) {
    task_node_t* n = (task_node_t*)calloc(1, sizeof(task_node_t));
    n->op = 1;
    n->str = strdup(url);  // 拷贝字符串所有权给队列
    enqueue_task(n);
    post_runnable();  // 通知主线程有任务
}

Fire-and-Forget 模式的核心价值

  • Go 的 goroutine 是抢占式调度,不保证在哪个线程执行。直接将 JNI 调用放在 goroutine 中会崩溃(JNIEnv 是线程绑定的)。
  • 解决方案:Go 层只负责入队,所有 JNI 操作统一在主线程的 native_run() 中执行
  • strdup(url) 拷贝字符串所有权到队列节点,Go 端调用后可以立即 C.free(cURL),互不干扰。
19.4.5 Bounds 合并和反馈环消除
c 复制代码
// 来源: FyneWebView/webview/webview_android.go(bounds 合并槽位)

// Fyne 每秒钟调用 Layout() 约60次。
// Bounds 合并槽位只存储最新值,并通过对比上次已应用的 bounds 来跳过重复调用,
// 打破 "setLayoutParams → native layout → Fyne 检测变化 → Layout → setBounds" 的循环。

static struct {
    int x, y, w, h;               // 最新请求值
    int applied_x, applied_y, applied_w, applied_h;  // 上次已应用到原生 WebView 的值
    volatile int pending;          // 是否有未应用的 bounds
} g_bounds;

void webview_set_bounds(int x, int y, int w, int h) {
    pthread_mutex_lock(&g_bounds_mutex);
    // 反馈环消除:如果 bounds 与上次已应用的完全相同,直接跳过
    if (x == g_bounds.applied_x && y == g_bounds.applied_y &&
        w == g_bounds.applied_w && h == g_bounds.applied_h) {
        g_bounds.pending = 0;
        pthread_mutex_unlock(&g_bounds_mutex);
        return;  // 不 post runnable,打断循环
    }
    // 新 bounds 不同,存储并 post
    g_bounds.x = x; g_bounds.y = y;
    g_bounds.w = w; g_bounds.h = h;
    g_bounds.pending = 1;
    pthread_mutex_unlock(&g_bounds_mutex);
    post_runnable();
}
19.4.6 RegisterNatives 动态方法注册
c 复制代码
// 来源: FyneWebView/webview/webview_android.go(RegisterNatives)

// Smali 文件声明了 native 方法但未实现。
// C 代码在运行时通过 RegisterNatives 将 native 方法绑定到 C 函数实现。

// 注册 NativeRunnable.run() → native_run()
JNINativeMethod methods[] = {
    {"run", "()V", (void*)native_run}
};
if ((*env)->RegisterNatives(env, g_nr_class, methods, 1) != 0) {
    return -3;  // 注册失败
}
g_nr_registered = 1;

// 注册 FocusRestoreTouchListener.onTouch() → native_on_touch()
JNINativeMethod ftl_methods[] = {
    {"onTouch", "(Landroid/view/View;Landroid/view/MotionEvent;)Z", (void*)native_on_touch}
};
(*env)->RegisterNatives(env, g_ftl_class, ftl_methods, 1);
19.4.7 焦点管理
c 复制代码
// 来源: FyneWebView/webview/webview_android.go(焦点管理)

// Fyne Entry(URL 工具栏)需要键盘时:添加 FLAG_NOT_FOCUSABLE,让系统焦点回到 Fyne
static void do_clear_focus(JNIEnv* env) {
    if (!g_wm_params || !g_window_manager) return;
    if (!g_webview_shown) return;  // 守卫:WebView 不可见时不操作

    jclass wmlpCls = (*env)->GetObjectClass(env, g_wm_params);
    jfieldID fFlags = (*env)->GetFieldID(env, wmlpCls, "flags", "I");
    int flags = (*env)->GetIntField(env, g_wm_params, fFlags);

    if (flags & 0x8) return;  // 已经是 NOT_FOCUSABLE,跳过

    flags |= 0x8;  // 添加 FLAG_NOT_FOCUSABLE
    (*env)->SetIntField(env, g_wm_params, fFlags, flags);

    // 调用 updateViewLayout 使标志生效
    jclass wmCls = (*env)->GetObjectClass(env, g_window_manager);
    jmethodID upd = (*env)->GetMethodID(env, wmCls,
        "updateViewLayout", "(Landroid/view/View;Landroid/view/ViewGroup$LayoutParams;)V");
    (*env)->CallVoidMethod(env, g_window_manager, upd, g_webview, g_wm_params);
}

// 用户触摸 WebView 时:移除 FLAG_NOT_FOCUSABLE,让 WebView 内的输入框获得 IME
static void do_request_focus(JNIEnv* env) {
    jclass wmlpCls = (*env)->GetObjectClass(env, g_wm_params);
    jfieldID fFlags = (*env)->GetFieldID(env, wmlpCls, "flags", "I");
    int flags = (*env)->GetIntField(env, g_wm_params, fFlags);

    if (!(flags & 0x8)) return;  // 已经是 focusable,跳过

    flags &= ~0x8;  // 清除 FLAG_NOT_FOCUSABLE
    (*env)->SetIntField(env, g_wm_params, fFlags, flags);

    (*env)->CallVoidMethod(env, g_window_manager, upd, g_webview, g_wm_params);
    // 同时显式调用 WebView.requestFocus() 确保 IME 可用
    (*env)->CallBooleanMethod(env, g_webview, rf);
}

焦点切换流程

  1. Fyne Entry 获取焦点clearFocus():给 WebView 窗口加 FLAG_NOT_FOCUSABLE,系统焦点回到 Fyne 窗口,Fyne Entry 可以使用 IME。
  2. 用户触摸 WebViewFocusRestoreTouchListener.onTouch()requestFocus():移除 FLAG_NOT_FOCUSABLE,WebView 恢复可聚焦状态,WebView 内的输入框可以使用 IME。
19.4.8 Go 层封装
go 复制代码
// 来源: FyneWebView/webview/webview_android.go(Go 层)

type androidWebView struct {
    nw         driver.NativeWindow
    msgHandler func(string)
}

// init 初始化 WebView(同步等待主线程完成创建)
func (w *androidWebView) init(parent fyne.Window, x, y, width, height int) error {
    nw, ok := parent.(driver.NativeWindow)
    if !ok {
        return fmt.Errorf("parent window does not implement driver.NativeWindow")
    }
    w.nw = nw

    done := make(chan error, 1)
    nw.RunNative(func(ctx any) {
        // RunNative 确保在 JNI 主线程上下文中执行
        aCtx, ok := ctx.(*driver.AndroidWindowContext)
        if !ok {
            done <- fmt.Errorf("unexpected context type: %T", ctx)
            return
        }

        // 存储 JavaVM 并传递 JNI 上下文
        C.webview_store_vm((*C.JavaVM)(unsafe.Pointer(aCtx.VM)))
        env := (*C.JNIEnv)(unsafe.Pointer(aCtx.Env))
        activity := C.jobject(unsafe.Pointer(aCtx.Ctx))

        ret := C.webview_init(env, activity,
            C.int(x), C.int(y), C.int(width), C.int(height))
        if ret != 0 {
            done <- fmt.Errorf("failed to create Android WebView (C code %d)", int(ret))
            return
        }
        done <- nil
    })
    return <-done
}

// loadURL 加载 URL(fire-and-forget)
func (w *androidWebView) loadURL(url string) {
    cURL := C.CString(url)
    defer C.free(unsafe.Pointer(cURL))
    C.webview_load_url(cURL)  // 立即返回,不等待
}

// loadHTML 加载 HTML(fire-and-forget)
func (w *androidWebView) loadHTML(html string) {
    cHTML := C.CString(html)
    defer C.free(unsafe.Pointer(cHTML))
    C.webview_load_html(cHTML)
}

// eval 执行 JavaScript(fire-and-forget)
func (w *androidWebView) eval(js string) {
    cJS := C.CString(js)
    defer C.free(unsafe.Pointer(cJS))
    C.webview_eval(cJS)
}

// setBounds 更新位置和大小(fire-and-forget + 合并)
func (w *androidWebView) setBounds(x, y, width, height int) {
    C.webview_set_bounds(C.int(x), C.int(y), C.int(width), C.int(height))
}

// destroy 销毁 WebView
func (w *androidWebView) destroy() {
    C.webview_destroy()
}

Go 层设计要点

  • driver.NativeWindow.RunNative() --- Fyne 提供的 JNI 线程安全机制,保证回调在正确的线程执行。
  • driver.AndroidWindowContext --- 包含 VM(JavaVM 指针)、Env(JNIEnv 指针)、Ctx(Activity 引用)。
  • init同步 的(<-done 等待主线程完成),其他操作是 fire-and-forget 异步的。

19.5 Smali 字节码注入

FyneWebView 项目中,native 方法的 Java 声明不是通过 .java 源文件,而是通过 Smali 字节码文件直接注入到 APK 中。Smali 是 Android Dalvik/ART 虚拟机的汇编语言。

smali 复制代码
; 来源: FyneWebView/tools/NativeRunnable.smali
; NativeRunnable --- 实现 Runnable 接口,方法在 C 代码中通过 RegisterNatives 注册

.class public Lcom/example/fynewebview/NativeRunnable;
.super Ljava/lang/Object;
.implements Ljava/lang/Runnable;

# 构造函数
.method public constructor <init>()V
    .registers 1
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V
    return-void
.end method

# native 方法声明 ------ 方法体为空,实现在 C 端通过 RegisterNatives 绑定
.method public native run()V
.end method
smali 复制代码
; 来源: FyneWebView/tools/FocusRestoreTouchListener.smali
; 触摸监听器 ------ 用户首次触摸 WebView 时恢复焦点

.class public Lcom/example/fynewebview/FocusRestoreTouchListener;
.super Ljava/lang/Object;
.implements Landroid/view/View$OnTouchListener;

.method public constructor <init>()V
    .registers 1
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V
    return-void
.end method

# 返回 false 表示不消耗触摸事件,让 WebView 正常处理
.method public native onTouch(Landroid/view/View;Landroid/view/MotionEvent;)Z
.end method

Smali 注入流程

  1. 编写 .smali 文件声明 native 方法(签名必须与 C 端 RegisterNatives 中的方法描述符完全一致)。
  2. 在构建过程中将 .smali 文件放入 smali/com/example/... 目录。
  3. apktool build 时 Smali 会被编译为 DEX 字节码。
  4. 运行时 C 代码通过 RegisterNatives 将 native 方法绑定到实际 C 函数。

19.6 Win32 Syscall 原生调用

FyneWebView 项目在 Windows 平台使用 syscall.NewLazyDLL 调用 Win32 API 创建子窗口,这是一个非 CGO 但功能类似的原生调用方案。

go 复制代码
// 来源: FyneWebView/webview/win32_windows.go

import (
    "syscall"
    "unsafe"
)

// Win32 API 函数指针(延迟加载,首次调用时才从 user32.dll 加载)
var (
    user32              = syscall.NewLazyDLL("user32.dll")
    procCreateWindowExW = user32.NewProc("CreateWindowExW")  // 创建窗口
    procMoveWindow      = user32.NewProc("MoveWindow")        // 移动/调整窗口
    procDestroyWindow   = user32.NewProc("DestroyWindow")      // 销毁窗口
    procShowWindow      = user32.NewProc("ShowWindow")         // 显示/隐藏窗口
)

// 窗口样式常量
const (
    ws_CHILD   = 0x40000000  // WS_CHILD:子窗口样式
    ws_VISIBLE = 0x10000000  // WS_VISIBLE:初始可见
    sw_HIDE    = 0           // SW_HIDE:隐藏
    sw_SHOW    = 5           // SW_SHOW:显示
)

// createChildWindow 在父窗口客户区创建 Win32 子窗口
// 使用 "Static" 控件类(轻量级空容器)作为 WebView2 控制器的宿主窗口
// 所有坐标是相对于父窗口客户区的物理像素
func createChildWindow(parentHwnd uintptr, x, y, w, h int) uintptr {
    className, _ := syscall.UTF16PtrFromString("Static")
    hwnd, _, _ := procCreateWindowExW.Call(
        0,                                      // dwExStyle --- 无扩展样式
        uintptr(unsafe.Pointer(className)),     // lpClassName --- "Static"
        0,                                      // lpWindowName --- 无窗口标题
        uintptr(ws_CHILD|ws_VISIBLE),           // dwStyle --- 子窗口+可见
        uintptr(x), uintptr(y),                 // 位置
        uintptr(w), uintptr(h),                 // 大小
        parentHwnd,                             // hWndParent --- 父窗口句柄
        0, 0, 0,                                // hMenu, hInstance, lpParam
    )
    return hwnd
}

// moveWindow 重新定位并调整窗口大小,自动重绘
func moveWindow(hwnd uintptr, x, y, w, h int) {
    procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(w), uintptr(h), 1)
}

// showWindow 显示或隐藏窗口
func showWindow(hwnd uintptr, visible bool) {
    if visible {
        procShowWindow.Call(hwnd, sw_SHOW)
    } else {
        procShowWindow.Call(hwnd, sw_HIDE)
    }
}

// destroyWindow 销毁窗口并释放资源
func destroyWindow(hwnd uintptr) {
    procDestroyWindow.Call(hwnd)
}

syscall.NewLazyDLL 对比 CGO

  • 不需要 import "C",纯 Go 代码,无跨平台编译问题。
  • 延迟加载:首次调用 Call() 时才真正加载 DLL,启动更快。
  • 类型系统:Go 端全部是 uintptr,需要手动管理 unsafe.Pointer 转换。
  • 适用场景:简单的 Windows API 调用,不需要编译 C 代码。

19.7 构建脚本与 CGO_LDFLAGS 配置

powershell 复制代码
# 来源: FyneCamera/build_android.ps1(构建流程关键部分)

# 1. 设置 NDK 环境变量
$env:ANDROID_HOME      = "D:\Android\Sdk"
$env:ANDROID_NDK_HOME  = "D:\Android\Sdk\ndk-bundle"

# 2. 配置 CGO_LDFLAGS --- 关键步骤
# gomobile 默认编译目标 API 21,但 NDK Camera2 库在 API 24+ 目录
# 必须手动添加 API 24 的库搜索路径,否则链接器找不到 libcamera2ndk/libmediandk
$SysrootLib = "$NdkPath\toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\lib"
$env:CGO_LDFLAGS = "-L$SysrootLib\aarch64-linux-android\24 " +
                   "-L$SysrootLib\arm-linux-androideabi\24 " +
                   "-L$SysrootLib\x86_64-linux-android\24 " +
                   "-L$SysrootLib\i686-linux-android\24"

# 3. fyne package 打包
fyne package -os android/arm64 --app-id "com.example.fynecamera" --name "FyneCamera" -icon Icon.png

# 4. apktool decode → 注入 CAMERA 权限 + minSdk=24 + 图标 → rebuild
java -jar apktool.jar d -f -o decoded FyneCamera.apk

# 注入权限声明到 AndroidManifest.xml
$permLines = '<uses-permission android:name="android.permission.CAMERA"/>...'
$manifest = $manifest -replace '(<application)', "$permLines    `$1"
# 修改 minSdkVersion 为 24(Camera2 API 最低要求)
$manifest = $manifest -replace 'android:minSdkVersion="\d+"', 'android:minSdkVersion="24"'

java -jar apktool.jar b -o FyneCamera_unsigned.apk decoded

# 5. zipalign + apksigner
zipalign -f 4 FyneCamera_unsigned.apk FyneCamera_aligned.apk
apksigner sign --ks debug.keystore --out FyneCamera-signed.apk FyneCamera_aligned.apk

构建流程总结

步骤 命令 目的
CGO 编译 fyne package -os android 编译 Go + C 代码为 native .so
APK 反编译 apktool d 解包 APK,准备注入资源
权限注入 修改 AndroidManifest.xml 添加 CAMERA 权限、提高 minSdk
图标注入 生成多密度 mipmap 设置应用图标
Smali 注入 放置 .smali 文件 注入 native 方法声明
APK 重打包 apktool b 重新打包为 APK
对齐 zipalign 4 字节边界对齐
签名 apksigner 使用 debug keystore 签名

19.8 自动化安装 build.bat

bat 复制代码
@echo off
echo === Building APK ===
powershell -ExecutionPolicy Bypass -File d:\FyneCamera\build_android.ps1
if %errorlevel% neq 0 (
    echo Build failed!
    pause
    exit /b %errorlevel%
)

echo === Installing to device ===
set DEVICE=
for /f "skip=1 tokens=1" %%d in ('adb devices 2^>nul') do (
    if not "%%d"=="" (
        set DEVICE=%%d
        goto :install
    )
)
echo No device connected!
pause
exit /b 1

:install
echo Target device: %DEVICE%
adb -s %DEVICE% uninstall com.example.fynecamera
adb -s %DEVICE% install -r d:\FyneCamera\FyneCamera-signed.apk
if %errorlevel% neq 0 (
    echo Install failed!
    pause
    exit /b %errorlevel%
)

echo === Done ===
pause

脚本流程

  1. 调用 build_android.ps1 完成 APK 构建(powershell -ExecutionPolicy Bypass
  2. 通过 adb devices 自动检测连接的 Android 设备
  3. 卸载旧版本 adb uninstall → 安装新版 adb install -r
  4. 每步失败均有错误提示和暂停,便于排查问题

附录:快速参考

包导入速查

用途 导入路径
核心类型 fyne.io/fyne/v2
应用入口 fyne.io/fyne/v2/app
容器布局 fyne.io/fyne/v2/container
标准控件 fyne.io/fyne/v2/widget
画布绘图 fyne.io/fyne/v2/canvas
布局系统 fyne.io/fyne/v2/layout
数据绑定 fyne.io/fyne/v2/data/binding
对话框 fyne.io/fyne/v2/dialog
主题 fyne.io/fyne/v2/theme
桌面特性 fyne.io/fyne/v2/driver/desktop
移动特性 fyne.io/fyne/v2/driver/mobile
文件存储 fyne.io/fyne/v2/storage
预定义颜色 golang.org/x/image/colornames

控件速查

控件 创建方法 所属项目
标签 widget.NewLabel("text") fynewh
按钮 widget.NewButton("text", callback) fynewh
输入框 widget.NewEntry() fynewh
密码框 widget.NewPasswordEntry() fynewh
多行文本 widget.NewMultiLineEntry() fynewh
下拉选择 widget.NewSelect(opts, callback) fynewh
可编辑下拉 widget.NewSelectEntry(opts) fynewh
复选框 widget.NewCheck("text", callback) fynewh
单选框 widget.NewRadioGroup(opts, callback) fynewh
列表 widget.NewList(lenFn, createFn, updateFn) fynewh
表格 widget.NewTable(sizeFn, createFn, updateFn) fynewh
滑块 widget.NewSlider(min, max) fynewh
进度条 widget.NewProgressBar() fynewh
折叠面板 widget.NewAccordion(items...) fynewh
富文本 widget.NewRichText(segments...) fynewh
树控件 widget.NewTreeWithStrings(map) fynewh
图标 widget.NewIcon(resource) fynewh
超链接 widget.NewHyperlink("text", url) fynewh
文本网格 widget.NewTextGridFromString("text") fynewh
菜单栏 fyne.NewMainMenu(menus...) fynewh
工具栏 widget.NewToolbar(items...) fynewh
选项卡 container.NewAppTabs(items...) fynewh

本文档所有示例代码提取自以下 5 个 Fyne 实战项目:

  • D:\OnlineExamApp --- 在线考试系统(SQLite + HTTP API + 自定义控件)
  • D:\FyneCamera --- 摄像头调用(Android + 系统命令)
  • D:\FyneWebView --- WebView 集成(内嵌浏览器)
  • D:\FundQuery --- 基金查询系统(数据展示 + 数据库操作)
  • D:\fynewh --- Fyne 全组件教学项目(覆盖几乎所有控件和功能)
相关推荐
_wyt0012 小时前
洛谷 P7912 [CSP-J 2021] 小熊的果篮 题解
c++·队列
Ch_champion2 小时前
2018年之前的Android 项目上使用的技术点(及第三方库)
android
龚礼鹏2 小时前
RK Android16 wifi 投屏失败问题排查
android
choumin3 小时前
创建型模式——原型模式
c++·设计模式·原型模式·创建型模式
code_pgf3 小时前
C/C++ 常用容器功能汇总
c语言·开发语言·c++
晓梦林4 小时前
[ACTF2020 新生赛]BackupFile学习笔记
android·笔记·学习
妙码生花4 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十六):多驱动上传接口
后端·go·ai编程
王维同学4 小时前
Credential Provider、Filter 与 PLAP 的 CLSID 枚举
c++·windows·安全·注册表
ITmaster07315 小时前
告别 IDE?Android CLI 来了,开发进入 AI Agent 时代
android·ide·人工智能