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 是唯一的解决方案。本章所有示例代码来自 FyneCamera 和 FyneWebView 两个实战项目。
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的关键设计):
- 先设置
closing = 1标志,阻止新帧回调写入- 停止并关闭 CaptureSession --- 停止触发新的 ImageReader 回调
usleep(200000)--- 等待 200ms 让正在执行的 binder 回调完成- 释放帧数据缓冲区
- 释放 CaptureRequest
- 关闭 CameraDevice(必须在 ImageReader 删除前)
- 删除 AImageReader(会自动释放 ANativeWindow,不要单独调用 ANativeWindow_release)
- 删除 CameraManager
- 销毁 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);
}
焦点切换流程:
- Fyne Entry 获取焦点 →
clearFocus():给 WebView 窗口加FLAG_NOT_FOCUSABLE,系统焦点回到 Fyne 窗口,Fyne Entry 可以使用 IME。- 用户触摸 WebView →
FocusRestoreTouchListener.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 注入流程:
- 编写
.smali文件声明 native 方法(签名必须与 C 端RegisterNatives中的方法描述符完全一致)。- 在构建过程中将
.smali文件放入smali/com/example/...目录。apktool build时 Smali 会被编译为 DEX 字节码。- 运行时 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 对齐 zipalign4 字节边界对齐 签名 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
脚本流程:
- 调用
build_android.ps1完成 APK 构建(powershell -ExecutionPolicy Bypass)- 通过
adb devices自动检测连接的 Android 设备- 卸载旧版本
adb uninstall→ 安装新版adb install -r- 每步失败均有错误提示和暂停,便于排查问题
附录:快速参考
包导入速查
| 用途 | 导入路径 |
|---|---|
| 核心类型 | 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 全组件教学项目(覆盖几乎所有控件和功能)