diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7df205d..afd080a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -387,3 +387,40 @@ jobs: ccFlag="" if [ "${{ matrix.compiler }}" != "tcc" ]; then ccFlag="-cc ${{ matrix.compiler }}"; fi ./php .github/scripts/run_tests.php $ccFlag + + # ============================================================ + # Android — NDK Clang 交叉编译 smoke test(仅验证 .so 生成,不打 APK) + # Android UI 测试需要模拟器,CI 仅验证 NDK 交叉编译链路可用 + # ============================================================ + android-ndk: + runs-on: ubuntu-latest + env: + PHP_URL: https://github.com/KingBes/TinyPHP/releases/download/PHP/php-cli-8.4-linux-x86_64-glibc.zip + steps: + - uses: actions/checkout@v4 + + - name: Setup Android NDK + run: | + NDK_VER="27.0.12077973" + NDK_PATH="$ANDROID_HOME/ndk/$NDK_VER" + if [ ! -d "$NDK_PATH" ]; then + echo "NDK $NDK_VER not pre-installed, installing..." + SDKMANAGER=$(command -v sdkmanager 2>/dev/null || echo "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager") + yes | "$SDKMANAGER" "ndk;$NDK_VER" > /dev/null 2>&1 + fi + echo "ANDROID_NDK=$NDK_PATH" >> $GITHUB_ENV + echo "Using NDK: $NDK_PATH" + + - name: Download PHP + run: | + curl -fsSL -o php.zip "$PHP_URL" + unzip -q php.zip + chmod +x php + + - name: Compile UI test to Android .so (x86_64) + run: | + # --no-android-apk 跳过 Gradle/APK 打包,仅验证 NDK 交叉编译生成 libtphp.so + ./php tphp.php test/ui/ui_basic.php -os android -arch x86_64 --no-android-apk + # 验证 .so 生成 + test -f build/android/jniLibs/x86_64/libtphp.so || { echo "ERROR: libtphp.so not found"; exit 1; } + file build/android/jniLibs/x86_64/libtphp.so diff --git a/.gitignore b/.gitignore index 9703a5c..fc4c020 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,11 @@ *.lib *.log *.tmp +*.apk +local.properties +.gradle/ build/ + tcc tcc-src/ *.phar diff --git a/CHANGELOG.md b/CHANGELOG.md index ed27034..7f7a4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,64 @@ --- -## [Unreleased] +## [0.2.0-beta.10] — 2026-08-04 ### 新增 +- **stdClass 动态属性对象支持**(`include/object/stdclass.h` + 编译器集成):兼容 PHP 原生 `new stdClass()`,支持字面量属性名的动态属性读写、isset/unset、foreach 遍历、`(object)`/`(array)` 类型转换、`get_object_vars()` 内置函数。 + - **AOT 兼容性**:仅支持字面量属性名(编译期已知),不支持 `$obj->$var` 动态属性名(与 `$$var` 同理,AOT 约束)。`json_decode` 保持返回 array(不改为 stdClass,因 JSON 键运行时才知道) + - **运行时实现**:`tphp_class_stdClass` 结构体(t_object header + t_array* props 动态属性表),基于 array.h 的哈希索引实现 O(1) 属性查找。提供 `new_stdClass`/`tphp_fn_stdclass_get/set/isset/unset/clone/count/from_array/to_array` 10 个运行时函数 + - **编译器特判**:visitNew/visitPropertyAccess/visitAssignPropStmt/isset/unset/foreach 在对象类型为 `tphp_class_stdClass*` 时生成运行时函数调用;新增 `wrapTVar` 辅助方法将任意类型包装为 t_var,`emitStdClassForeach` 生成属性遍历代码 + - **类型转换**:`(object) $array` 通过 `tphp_fn_stdclass_from_array` 复制键值对创建 stdClass;`(array) $stdClass` 通过 `tphp_fn_stdclass_to_array` 提取属性表为关联数组 + - **var_dump 适配**:stdClass 输出 PHP 兼容格式 `object(stdClass)#N (count) { ["k"]=> val ... }`,递归显示所有动态属性 + - **isset 语义**:与 PHP 一致,null 值属性返回 false(不仅检查键存在性,还检查值非 null) + - **Parser 支持**:新增 `(object)` cast 类型识别(Lexer 中 `object` 非类型关键字,需 Parser 特判) + - **测试覆盖**:6 个测试文件(basic/cast/isset_unset/foreach/functions/edge),TCC/GCC/Clang 三编译器全部通过 + +### 修复 + +- **macOS Metal shader 缺失**(`ext/ui/src/ui.h`):shader 源码只有 `SOKOL_GLCORE` 和 `SOKOL_GLES3` 两个条件编译分支,macOS 使用 `SOKOL_METAL` 后端时 `_sr_vs_src`/`_sr_fs_src` 未声明导致编译失败。添加 `SOKOL_METAL` 分支提供 MSL(Metal Shading Language)源码,通过 `_SR_VS_ENTRY`/`_SR_FS_ENTRY` 宏为 Metal 后端设置显式 entry 函数名(`vs_main`/`fs_main`) +- **Android NDK CI 报错 "built-in TCC not found"**(`tphp.php`):`-os android` 时默认尝试找 TCC,但 Linux CI 环境未安装 TCC。Android 目标使用 NDK clang,跳过 TCC 存在性检查;`$ccClass` 在条件编译求值阶段(Phase 1)提前设为 'Clang'(而非等到 NDK 探测逻辑 Phase 2) + +## [0.2.0-beta.9] — 2026-08-02 + +### 新增 + +- **Android NDK 交叉编译支持**(`tphp.php` + `ext/ui/`):新增 `-os android` 编译目标,使用 NDK Clang 工具链将 PHP 编译为 Android 共享库(`libtphp.so`)并打包为 APK。 + - **环境变量**:`ANDROID_NDK`(必需,NDK 根目录)、`JAVA_HOME`(JDK 17/21,APK 打包必需,Java 24+ 不兼容 Gradle 8.9)、`ANDROID_HOME`(Android SDK 路径)、`TPHP_ANDROID_API`(默认 24 = Android 7.0) + - **多 ABI 编译**:默认编译全部 4 种 ABI(arm64-v8a / x86_64 / armeabi-v7a / x86),覆盖真机与模拟器;可通过 `-arch` 指定单 ABI(`aarch64`/`x86_64`/`armv7a`/`i686`) + - **APK 输出机制**:通过子进程递归调用编译所有 ABI 的 `libtphp.so`(输出到 `build/android/jniLibs//`),随后调用 Gradle 打包并复制 APK 到当前工作目录(`-debug.apk`,遵循 `-o` 机制,如 `-o myapp` → `myapp-debug.apk`);`ext/ui/android/` 仅作为工程模板,不写入任何产物 + - **NDK 工具链**:自动检测 NDK 路径,添加 `-D__ANDROID__` 宏定义和 NDK 内置 sysroot;链接 `-landroid -lEGL -lGLESv3 -llog` + - **条件编译**:新增 `#if Android` 标识符(Lexer/Parser 支持 `android` 平台前缀识别) + - **Java 版本兼容**:tphp.php 自动检测 Java 版本,Java 24+ 与 Gradle 8.9/AGP 8.7.0 不兼容时搜索 Java 17/21 LTS 并通过 `putenv('JAVA_HOME=...')` 切换 + - **SDK 许可自动补写**:检测 `android-sdk-license` 文件缺失哈希时自动追加 `d56f5187479451eabf01fb78af698cb`(SDK 34 许可) + - **SDK source.properties 临时重命名**:构建前临时将 SDK 根目录的 `source.properties` 重命名为 `.tphp_bak`,避免 AGP LegacyLocalRepoLoader 误判根目录为 package 而无法发现 `platforms/` 下的平台包;构建后恢复 + - **国内镜像加速**:Gradle 配置腾讯云 Gradle 镜像和国内 Maven 镜像,解决 SSL 证书和下载速度问题 + - **Android 工程模板**:`ext/ui/android/` 包含 Gradle Wrapper、`build.gradle`、`settings.gradle`、`AndroidManifest.xml`、`MainActivity.kt` 等 8 个文件,`compileSdk/targetSdk/buildToolsVersion` 统一为 35/35/35.0.0 + - **.gitignore**:排除 `local.properties`(包含开发者个人 SDK 路径) + +- **Android 平台适配**(`ext/ui/src/ui.h`):移动端 UI 适配,包括 JNI 软键盘、触摸事件转换、原生按键事件拦截、stdout 重定向等。 + - **JNI 软键盘桥接**:通过 JNI 调用 Android `InputMethodManager` 显示/隐藏软键盘。`_ui_android_show_softinput`/`_ui_android_hide_softinput` 通过 `AttachCurrentThread` 安全附加线程,`_ui_jni_check` 检查异常并释放局部引用,避免引用表溢出 + - **触摸事件 → 鼠标事件转换**:sokol 在 Android 上生成 `TOUCHES_BEGAN/ENDED/MOVED` 事件,`_ui_sokol_event_cb` 中将首个触摸点转换为 `MOUSE_DOWN/UP/MOVE` 事件,使桌面端 PHP 代码无需修改即可在 Android 上响应触摸 + - **原生按键事件拦截**:sokol 的 `_sapp_android_key_event` 只处理 BACK 键,其他按键直接丢弃导致 TextBox 无法输入。通过 `native_event_cb` 钩子拦截 `AInputEvent`,`_ui_android_map_keycode` 将 `AKEYCODE_*` 映射为 PHP Key 枚举的 ASCII 值,构造 `sapp_event` 并调用 `_ui_sokol_event_cb` 分发;对可打印字符(ASCII 32-126)额外生成 `CHAR` 事件驱动文本输入 + - **GLES3 后端**:Android 使用 `SOKOL_GLES3` 后端和 `SOKOL_NO_ENTRY`,通过 `sokol_main()` 作为入口(而非 `main()`)。GL 版本必须为 GLES 3.0(不能用桌面 GL 3.3,否则 `eglCreateContext` 失败秒退) + - **NativeActivity 生命周期**:Android 入口为 `ANativeActivity_onCreate`(sokol 提供),此时 PHP 的 `Main::main()` 未执行。`sokol_main()` 首次调用时执行 `tphp_android_main()` 填充 desc;CodeGenerator 在 Android 模式下生成 `tphp_android_main()` 替代 `main()`,且不释放 Main 对象和 `_argv`(避免闭包悬垂指针) + - **stdout/stderr 重定向**:Android NativeActivity 的 stdout 默认不输出到 logcat。用 pipe + 后台线程将 stdout/stderr 重定向到 `__android_log_write`,便于调试 + - **默认竖屏**:`AndroidManifest.xml` 中 `android:screenOrientation` 设置为 `portrait`(非 `landscape`) + - **字体渲染**:使用内置 font8x8 点阵字体表,跨平台通用(替代 Win32 GDI 字体生成,Android 上不可用) + +- **ext/ui GPU 即时模式形状渲染器**(`ext/ui/src/ui.h`):实现基于 sokol_gfx 的即时模式形状渲染,补全 GPU 后端的 `fill_rect`/`draw_line`/`draw_rect`/`draw_circle`(此前 GPU 后端仅实现 `begin_pass`/`end_pass`,形状绘制抛异常提示)。 + - **GPU 后端实现**:内存顶点缓冲区收集形状数据,每帧 `sg_apply_pipeline` + `sg_update_buffer` 上传 GPU 绘制。着色器使用 sokol shader 生成(顶点位置 + 颜色)。`usage.stream_update=true` 适配新版 sokol_gfx API + - **sokol_gfx 新版 API 适配**:`_sr_init()` 适配新版 sokol_gfx API,使用结构体字段(`.usage.stream_update`、`.vertex_func`/`.fragment_func`)替代旧版枚举值 + - **DrawDevice 抽象**见下方「变更」部分「ext/ui GPU → CPU 软件渲染自动回退」条目 + - **ext/ui**(`ext/ui/`):基于 sokol C 库的跨平台图形界面扩展(纯 phpc 模式)。9 个 PHP 类 + 9 个枚举,覆盖窗口管理、2D 绘图、控件体系、布局系统和事件处理。 - **底层依赖**:内置 sokol C 库源码(sokol_app/sokol_gfx/sokol_glue/sokol_log/sokol_time),位于 `ext/ui/sokol/`,零运行时依赖 - - **平台后端**:Windows/Linux = OpenGL (SOKOL_GLCORE),macOS = Metal (SOKOL_METAL)。TCC 缺失 `windowsx.h`,故 Windows 使用 OpenGL 而非 D3D11 + - **平台后端**:Windows/Linux = OpenGL (SOKOL_GLCORE),macOS = Metal (SOKOL_METAL),Android = GLES3 (SOKOL_GLES3)。TCC 缺失 `windowsx.h`,故 Windows 使用 OpenGL 而非 D3D11 - **核心类**:`App`(窗口生命周期 + onInit/onFrame/onEvent 回调)、`Window`(静态:width/height/dpiScale/setCursor)、`Event`(fromPtr 解析 sapp_event 指针)、`Color`(RGBA + toUint 0xAABBGGRR)、`Rect`(contains 命中测试)、`Graphics`(静态:clear/fillRect/drawText/drawLine/drawRect/drawCircle) - **控件体系**:`Widget` 抽象基类(init/draw/setPos/proposeSize/pointInside + 事件方法)、`WidgetContainer`(addChild/drawAll/hitTestIndex/dispatch*)、`Button`(press/release/click + onClick)、`Label`、`TextBox`(focus/blur/handleKeyDown/handleChar)、`CheckBox`(toggle/setChecked + onChange)、`Slider`(beginDrag/drag/endDrag/setValue + 值夹紧) - **布局系统**:`Layout` 抽象基类(addWidget/updateLayout/asWidget)、`Stack`(flex 风格,row/column 静态方法,Compact/Stretch/Fixed 尺寸模式)、`CanvasLayout`(绝对定位) - - **软键盘桥接**:`SoftInput` 静态类(show/hide/isVisible/onInput/dispatch/clear),桌面端 show/hide 为 no-op,移动端未来支持 + - **软键盘桥接**:`SoftInput` 静态类(show/hide/isVisible/onInput/dispatch/clear),桌面端 show/hide 为 no-op,Android 端通过 JNI 调用 `InputMethodManager` 实现(详见上方「Android 平台适配」) - **枚举**:EventType(22 值)/ Key(ASCII+VK_*)/ MouseButton / KeyMod / Cursor / Direction / WidgetState / LayoutAlign / ChildSize - **绘图契约**:所有 `Graphics::*` 方法必须在 `onFrame` 回调内调用,否则抛 `Exception("drawing outside frame callback")` - **事件契约**:sokol `sapp_event*` 指针以 `t_int` 流转(intptr_t 转换),PHP 侧通过 `Event::fromPtr($evPtr)` 解析 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7e7490..a70b1a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -357,15 +357,17 @@ ext/ │ ├── posix.c │ └── posix.php ← #flag posix.c + #include posix.h └── ui/ ← 图形界面(纯 phpc + 内置 sokol C 库) + ├── android/ ← Android 工程模板(Gradle Wrapper + build.gradle + AndroidManifest.xml + MainActivity.kt) ├── compat/ ← TCC 兼容头文件(sal.h / shellapi.h / windowsx.h) ├── sokol/ ← sokol C 库源码(sokol_app/gfx/glue/log/time .h) └── src/ - ├── ui.h ← C 包装层(tphp_fn_ui_* + sokol 回调桥) + ├── ui.h ← C 包装层(tphp_fn_ui_* + sokol 回调桥 + DrawDevice 抽象 + Android JNI/按键/触摸适配) + ├── ui_cpu.h ← CPU 软件渲染后端(Win32 DIB + GDI,无 GPU 环境回退) ├── ui.php ← App/Window/Event/Color/Rect/Graphics ├── ui_enums.php ← EventType/Key/MouseButton/KeyMod/Cursor/Direction/WidgetState/LayoutAlign/ChildSize ├── ui_widget.php ← Widget/Button/Label/TextBox/CheckBox/Slider/WidgetContainer ├── ui_layout.php ← Layout/Stack/CanvasLayout - └── ui_softinput.php ← SoftInput(软键盘桥接) + └── ui_softinput.php ← SoftInput(软键盘桥接,桌面端 no-op,Android 端 JNI 实现) ``` **两种实现方式,可混用:** diff --git a/FUNCTIONS.md b/FUNCTIONS.md index b620d46..f4f7df2 100644 --- a/FUNCTIONS.md +++ b/FUNCTIONS.md @@ -138,10 +138,10 @@ calc(100, 20, 30); // 150 (100 + 20 + 30) | php函数 | tphp函数 | 性能说明 | 差异说明 | |------|--------|------|------| | `echo $expr` | `echo $expr` | `fwrite(stdout)` + 每次调用 `fflush`,二进制安全,不解析 `%` 格式化符;O(n) 零堆分配 | PHP 支持 `echo $a, $b` 多参数语法,tphp 单参数(多参数由编译器展开为多次调用) | -| `var_dump(mixed $value, mixed ...$rest): void` | `var_dump(mixed $value): void` | type switch → `fprintf`/`fwrite` 递归输出,O(节点数),零中间缓冲 | 单参数;浮点 `%g`(6 位有效数字)非 PHP `%.14G`(14 位);对象输出 `object(ClassName)#`(id 为全局递增唯一标识,符合标准 PHP 行为),无属性列表 | +| `var_dump(mixed $value, mixed ...$rest): void` | `var_dump(mixed $value): void` | type switch → `fprintf`/`fwrite` 递归输出,O(节点数),零中间缓冲 | 单参数;浮点 `%g`(6 位有效数字)非 PHP `%.14G`(14 位);普通对象输出 `object(ClassName)#`(无属性列表);stdClass 输出完整属性列表 `object(stdClass)# (count) { ["k"]=> val ... }` | | `print_r(mixed $value, bool $return = false): string\|true` | `print_r(mixed $value): void` | 递归格式化,O(节点数),流式写入 stdout | **无 `$return` 参数**,始终返回 void;对象仅输出 `ClassName Object` 无属性;无循环引用检测(递归数组会栈溢出) | | `exit(int\|string $status): void` | `exit(int $code): void` | `exit(code)` 单次 libc 调用,O(1) | 仅接受 int(PHP 还接受 string 消息);无 shutdown 回调 | -| `isset(mixed $var, mixed ...$rest): bool` | `isset(mixed $var): bool` | 指针类型 → `ptr != NULL`;值类型 → 编译期 `true`;O(1) | 单参数;语义为指针 NULL 检查(值类型如 int/string 永远返回 true) | +| `isset(mixed $var, mixed ...$rest): bool` | `isset(mixed $var): bool` | 指针类型 → `ptr != NULL`;值类型 → 编译期 `true`;O(1) | 单参数;语义为指针 NULL 检查(值类型如 int/string 永远返回 true);`isset($obj->prop)` 对 stdClass 检查属性存在且值非 null(与 PHP 一致) | | `empty(mixed $var): bool` | `empty(mixed $var): bool` | 按类型分发内联:int→`==0`、string→`is_falsy`、float/bool/null 同;O(1) | — | --- @@ -2700,8 +2700,17 @@ try { > 纯 phpc 模式:C 包装函数在 `ui.h`,PHP 类在 `ui*.php`。所有 UI 类使用 `namespace UI`, > 用户通过 `use UI\App;` 引入。`#import ui` 自动加载全部 PHP 源文件。 > -> **平台后端**:Windows/Linux = OpenGL (SOKOL_GLCORE),macOS = Metal (SOKOL_METAL)。 -> TCC 缺失 `windowsx.h`,故 Windows 使用 OpenGL 而非 D3D11。GL 版本固定为 3.3 Core(覆盖绝大多数桌面 GPU,比 sokol 默认 4.3 兼容性更好)。 +> **平台后端**:Windows/Linux = OpenGL (SOKOL_GLCORE),macOS = Metal (SOKOL_METAL),Android = GLES3 (SOKOL_GLES3 + SOKOL_NO_ENTRY)。 +> TCC 缺失 `windowsx.h`,故 Windows 使用 OpenGL 而非 D3D11。GL 版本固定为 3.3 Core(覆盖绝大多数桌面 GPU,比 sokol 默认 4.3 兼容性更好)。Android GLES 版本为 3.0(不能用桌面 GL 3.3,否则 `eglCreateContext` 失败秒退)。 +> +> **Android 平台适配**(`-os android`): +> - **入口机制**:Android NativeActivity 模式下入口为 `ANativeActivity_onCreate`(sokol 提供),而非 `main()`。sokol 在其中调用 `sokol_main()` 获取 desc,此时 PHP 的 `Main::main()` 未执行。`sokol_main()` 首次调用时执行 `tphp_android_main()` 填充全局 desc;CodeGenerator 在 Android 模式下生成 `tphp_android_main()` 替代 `main()`,且不释放 Main 对象和 `_argv`(避免闭包悬垂指针) +> - **JNI 软键盘**:`SoftInput::show()`/`hide()` 通过 JNI 调用 Android `InputMethodManager`。`_ui_android_show_softinput`/`_ui_android_hide_softinput` 使用 `AttachCurrentThread` 安全附加线程,`_ui_jni_check` 检查异常并释放局部引用 +> - **触摸事件转换**:sokol 在 Android 上生成 `TOUCHES_BEGAN/ENDED/MOVED` 事件,`_ui_sokol_event_cb` 中将首个触摸点转换为 `MOUSE_DOWN/UP/MOVE` 事件,桌面端 PHP 代码无需修改即可在 Android 上响应触摸 +> - **原生按键事件拦截**:sokol 的 `_sapp_android_key_event` 只处理 BACK 键,其他按键直接丢弃。通过 `native_event_cb` 钩子拦截 `AInputEvent`,`_ui_android_map_keycode` 将 `AKEYCODE_*` 映射为 PHP Key 枚举的 ASCII 值(A-Z/0-9/空格/回车/退格/方向键等),构造 `sapp_event` 并调用 `_ui_sokol_event_cb` 分发;对可打印字符(ASCII 32-126)额外生成 `CHAR` 事件驱动 TextBox 输入 +> - **stdout 重定向**:Android NativeActivity 的 stdout 默认不输出到 logcat。用 pipe + 后台线程将 stdout/stderr 重定向到 `__android_log_write`(tag: `tphp`),便于调试 +> - **默认竖屏**:`AndroidManifest.xml` 中 `android:screenOrientation` 设置为 `portrait` +> - **字体渲染**:使用内置 font8x8 点阵字体表,跨平台通用(替代 Win32 GDI 字体生成,Android 上不可用) > > **后端自动选择(GPU → CPU 软件渲染回退)**: > `ui_app_run` 先尝试 sokol(GPU)后端,若初始化失败(如无硬件 GPU、RDP/虚拟机环境下 WGL 像素格式选择失败 `WIN32_WGL_FIND_PIXELFORMAT_FAILED`),sokol panic 经 `_ui_slog_func` 转为 `tp_throw`,`ui_app_run` 捕获后自动回退到 CPU 软件渲染后端(`_cpu_app_run`,输出 `[ui] GPU backend unavailable, falling back to CPU software renderer`)。整个过程对 PHP 侧透明,用户代码无需任何改动。 @@ -2716,7 +2725,7 @@ try { > - **`sapp_run` 异常包裹 + 自动回退**:`ui_app_run` 用 `TP_TRY`/`TP_CATCH_ANY` 包裹 `sapp_run`,sokol 初始化 panic(如 `WIN32_WGL_FIND_PIXELFORMAT_FAILED`)被捕获后自动回退到 CPU 软件渲染后端,而非直接返回错误或 `exit(1)`。 > - **pass 自动收尾**:`ui_state_t.pass_active` 跟踪 `begin_pass` 状态,frame 回调末尾自动调用 `end_pass`,即使 PHP 回调中途抛异常也不会漏掉,防止后端状态不一致导致下一帧渲染崩溃。 > -> **绘图契约**:所有 `Graphics::*` 方法必须在 `onFrame` 回调内调用,否则抛 `Exception("drawing outside frame callback")`;无绘图设备时抛 `Exception("no draw device initialized")`。`ui_clear` 调用 `device->begin_pass` 并设置 `pass_active=true`;`ui_end_frame` 调用 `device->end_pass` 手动结束 pass(可选,frame 回调会自动调用);无 active pass 时调 `ui_end_frame` 抛 `Exception("end_frame called without active pass")`。GPU 后端的形状绘制(`fill_rect` 等)尚未实现时抛异常提示(无 GPU 环境会自动回退到 CPU 后端,CPU 后端全部已实现)。 +> **绘图契约**:所有 `Graphics::*` 方法必须在 `onFrame` 回调内调用,否则抛 `Exception("drawing outside frame callback")`;无绘图设备时抛 `Exception("no draw device initialized")`。`ui_clear` 调用 `device->begin_pass` 并设置 `pass_active=true`;`ui_end_frame` 调用 `device->end_pass` 手动结束 pass(可选,frame 回调会自动调用);无 active pass 时调 `ui_end_frame` 抛 `Exception("end_frame called without active pass")`。GPU 后端(sokol_gfx)和 CPU 后端(ui_cpu.h)均实现全部形状绘制函数(`fill_rect`/`draw_line`/`draw_rect`/`draw_circle`),无 GPU 环境自动回退到 CPU 后端。 > > **参数校验**(不静默处理): > - `App::__construct` / `ui_app_run`:width/height 必须 > 0,否则抛 `Exception("app_run: invalid window dimensions")` @@ -2732,6 +2741,7 @@ try { > - Windows:`-lgdi32 -luser32 -lopengl32 -lshell32` > - Linux:`-lX11 -lGL -lXi -lXcursor -ldl -lpthread` > - macOS:`-framework Cocoa -framework MetalKit -framework Metal` +> - Android:`-landroid -lEGL -lGLESv3 -llog`(NDK Clang 工具链,`-os android` 时) > > **命名空间**:所有 UI 类位于 `UI` 命名空间下,通过 `use UI\App;` 等引入。C 包装函数使用 `tphp_fn__ui_*` 双下划线前缀(内部使用,不暴露给用户空间);PHP 侧通过 `_ui_*()` 函数调用(带前导下划线,约定为内部使用)。用户通过 OOP 类使用 UI 功能,不直接调用 `_ui_*` 函数。 @@ -2979,12 +2989,13 @@ try { ### SoftInput 类 — 软键盘管理(静态) > 桌面端 `show`/`hide` 为 no-op(有物理键盘),`isVisible` 始终返回 false。 -> 移动端未来调用平台原生 API。回调存储在 C 层 `_ui_softinput_cb`。 +> Android 端通过 JNI 调用 `InputMethodManager` 实现(`_ui_android_show_softinput`/`_ui_android_hide_softinput`)。 +> 回调存储在 C 层 `_ui_softinput_cb`。 | 方法 | 签名 | 说明 | |------|------|------| -| `show` (静态) | `(): void` | 显示软键盘(桌面端 no-op) | -| `hide` (静态) | `(): void` | 隐藏软键盘(桌面端 no-op) | +| `show` (静态) | `(): void` | 显示软键盘(桌面端 no-op,Android 端 JNI 调用 InputMethodManager) | +| `hide` (静态) | `(): void` | 隐藏软键盘(桌面端 no-op,Android 端 JNI 调用 InputMethodManager) | | `isVisible` (静态) | `(): bool` | 软键盘是否可见 | | `onInput` (静态) | `(callable $cb): void` | 注册字符输入回调(`function(int $codepoint): void`) | | `dispatch` (静态) | `(int $codepoint): void` | 分发字符输入(由 `Char` 事件触发) | @@ -3095,6 +3106,28 @@ class Main { --- +### stdClass 动态属性对象 + +> 文件: `object/stdclass.h` + +PHP 原生 `stdClass` 兼容实现,作为动态属性容器。基于 `t_array` 哈希索引存储字符串键到 `t_var` 的映射。 + +| 特性 | 实现 | 说明 | +|------|------|------| +| `new stdClass()` | `new_stdClass()` | 分配 `tphp_class_stdClass`(t_object header + t_array* props),初始化空属性表 | +| `$obj->prop = $v` | `tphp_fn_stdclass_set(obj, STR_LIT("prop"), VAR_XXX(v))` | 字面量属性名赋值,`wrapTVar` 按源类型包装为 t_var | +| `$obj->prop` | `tphp_fn_stdclass_get(obj, STR_LIT("prop"))` | 字面量属性名读取,返回 t_var;未定义返回 VAR_NULL | +| `isset($obj->prop)` | `tphp_fn_stdclass_isset(obj, STR_LIT("prop"))` | PHP 语义:键存在且值非 null 才返回 true | +| `unset($obj->prop)` | `tphp_fn_stdclass_unset(obj, STR_LIT("prop"))` | 从属性表移除指定键 | +| `foreach ($obj as $k => $v)` | `emitStdClassForeach` | 遍历内部 t_array,$k 为 t_string,$v 为 t_var | +| `(object) $array` | `tphp_fn_stdclass_from_array(arr)` | 复制数组键值对为 stdClass 属性 | +| `(array) $stdClass` | `tphp_fn_stdclass_to_array(obj)` | 提取属性表为关联数组 | +| `get_object_vars($obj)` | `tphp_fn_stdclass_to_array(obj)` | 返回属性关联数组(仅支持 stdClass) | +| `clone $obj` | `tphp_fn_stdclass_clone(obj)` | 深拷贝属性表(运行时已就绪,待 clone 语法接入) | +| `var_dump($obj)` | 递归输出属性 | `object(stdClass)#N (count) { ["k"]=> val ... }` | + +> **AOT 约束**:仅支持字面量属性名(编译期已知)。不支持 `$obj->$var` 动态属性名(与 `$$var` 同理)。`json_decode` 保持返回 array(JSON 键运行时才知道,返回 stdClass 后无法用字面量属性名访问)。 + ## Generator / yield > 文件: `object/generator.h` + `include/minicoro.h` diff --git a/GRAMMAR.md b/GRAMMAR.md index 06e0b74..a9cf80c 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -73,6 +73,7 @@ class C { | `never` | `void` | 永不返回(exit/throw) | | `mixed` | `t_var` | 标签联合体,有运行时开销 | | `Generator` | `tphp_class_Generator*` | 协程对象(minicoro 实现,stackless) | +| `stdClass` | `tphp_class_stdClass*` | 动态属性容器(基于 t_array 哈希索引) | | `Thread`/`Mutex`/`CondVar`/`WaitGroup` | `tphp_class_X*` | 多线程 COS 类(tinycthread 封装,Thread-Local 运行时) | | `Channel`/`Future` | `tphp_class_X*` | 异步与协程通信 COS 类(参考 vlang CSP 模型,自旋 750 次 + 环形缓冲区零 malloc) | | 类类型 | `tphp_class_X*` 或 `tphp_na_Ns_tphp_class_X*` | COS 对象指针(命名空间类带 `tphp_na_` 前缀) | @@ -777,6 +778,7 @@ primary: | `(int)` | INT_CAST | `(t_int)` | ✅ | | `(float)` | FLOAT_CAST | `(t_float)` | ✅ | | `(string)` | STRING_CAST | 按类型转换 | ✅ | +| `(object)` | OBJECT_CAST | 数组 → stdClass | ✅ | | `(bool)` | BOOL_CAST | 按假值规则 | ✅ | --- @@ -827,11 +829,15 @@ __TRAIT__ ❌ | OS | `Windows` / `Win` | 目标 OS = Windows | | | `Linux` | 目标 OS = Linux | | | `MacOS` / `Darwin` / `Mac` | 目标 OS = Darwin | +| | `Android` | 目标 OS = Android(`-os android`) | | 编译器 | `TCC` / `TinyC` | 当前编译器类 = TCC | | | `GCC` | 当前编译器类 = GCC | | | `Clang` | 当前编译器类 = Clang | +| | `NDK` / `NDKClang` | 当前编译器类 = NDK Clang(`-os android` 时) | | 架构 | `x86_64` / `amd64` / `x64` | 目标架构 = x86_64 | | | `aarch64` / `arm64` | 目标架构 = aarch64 | +| | `armv7a` / `armeabi-v7a` | 目标架构 = armeabi-v7a(仅 Android) | +| | `i686` / `x86` | 目标架构 = x86(仅 Android) | | 模式 | `debug` | `--debug` 模式 | | | `prod` | 非 `--debug` 模式 | @@ -998,7 +1004,7 @@ phpc_ptr_bridge: | `#cstruct Name { C.type field; ... }` | 声明 C 结构体字段布局,`$p->field` 原生访问 | | `#debug expected` | 测试预期输出(`--debug` 模式) | | `#import name` | 按需引入扩展(自动加载 ext/name/src/) | -| `#if`/`#elseif`/`#else`/`#endif` | 条件编译:解析期求值,非命中分支跳过不生成代码。支持顶层和函数体内,条件表达式含 `Windows`/`Linux`/`Darwin`/`TCC`/`GCC`/`Clang`/`x86_64`/`aarch64`/`debug`/`prod` 标识符 + `!`/`&&`/`\|\|`/`()` 组合(详见 §11.1) | +| `#if`/`#elseif`/`#else`/`#endif` | 条件编译:解析期求值,非命中分支跳过不生成代码。支持顶层和函数体内,条件表达式含 `Windows`/`Linux`/`Darwin`/`Android`/`TCC`/`GCC`/`Clang`/`NDK`/`x86_64`/`aarch64`/`armv7a`/`i686`/`debug`/`prod` 标识符 + `!`/`&&`/`\|\|`/`()` 组合(详见 §11.1) | | `C->func(args)` | 直接 C 函数调用 | | `C->CONST` | 直接 C 常量/枚举/宏访问(无括号时按 `t_int` 推断) | | `C.Type` | C 类型注解(函数参数/返回值。值类型 `C.int`→`int`/`C.double`→`double`/`C.char`→`char`;定宽 `C.int32`→`int32_t`/`C.uint64`→`uint64_t`;指针 `C.void*`→`void*`/`C.Point*`→`Point*`,用 `*` 后缀) | diff --git a/QUICK_START.md b/QUICK_START.md index ff886e5..df8d17b 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -254,7 +254,54 @@ php tphp.php async_demo.php --debug --- -## 5. 开发流程 +## 5. Android 构建 + +TinyPHP 支持将 PHP UI 应用编译为 Android APK。使用 NDK Clang 工具链交叉编译为 `libtphp.so`,通过 Gradle 打包为 APK。 + +### 前置条件 + +| 环境变量 | 说明 | +|----------|------| +| `ANDROID_NDK` | NDK 根目录(必需,如 `C:\Android\ndk\27.0.12077973`) | +| `JAVA_HOME` | JDK 17/21 路径(APK 打包必需,Java 24+ 不兼容 Gradle 8.9) | +| `ANDROID_HOME` | Android SDK 路径(APK 打包必需) | +| `TPHP_ANDROID_API` | 目标 API 级别(默认 24 = Android 7.0) | + +### 一键编译 + +```bash +# 默认编译全部 4 种 ABI,生成 -debug.apk +php tphp.php test/ui/ui_basic.php -os android + +# 指定输出名(生成 myapp-debug.apk) +php tphp.php test/ui/ui_basic.php -os android -o myapp + +# 仅编译单 ABI(加速本地测试) +php tphp.php test/ui/ui_basic.php -os android -arch x86_64 # 模拟器 +php tphp.php test/ui/ui_basic.php -os android -arch aarch64 # 真机 +``` + +### 产物位置 + +| 产物 | 路径 | 说明 | +|------|------|------| +| `libtphp.so` | `build/android/jniLibs//` | 各 ABI 共享库(供 Gradle 打包) | +| `-debug.apk` | 当前工作目录 | 最终 APK | + +### 支持的 ABI + +| `-arch` 值 | ABI | 典型场景 | +|----------|-----|---------| +| `aarch64` | arm64-v8a | 64 位真机 | +| `x86_64` | x86_64 | 64 位模拟器 | +| `armv7a` | armeabi-v7a | 32 位真机 | +| `i686` | x86 | 32 位模拟器 | + +> 仅设置 `ANDROID_NDK` 而不设置 `JAVA_HOME`/`ANDROID_HOME` 时,跳过 APK 打包但仍生成 `libtphp.so`。详见 [ext/ui/android/README.md](ext/ui/android/README.md)。 + +--- + +## 6. 开发流程 ```text 改代码 → 跑相关测试 → 全量 CI → PR @@ -273,7 +320,7 @@ php tphp.php async_demo.php --debug --- -## 6. 代码约定 +## 7. 代码约定 | 文件 | 职责 | |---|---| @@ -289,7 +336,7 @@ php tphp.php async_demo.php --debug --- -## 7. 常见问题 +## 8. 常见问题 **Q: 编译报 `macro used with too many args`?** diff --git a/README.md b/README.md index 46537e1..12218c3 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ php tphp.php main.php -os linux # x86_64 Linux php tphp.php main.php -os linux -arch aarch64 # ARM64 Linux php tphp.php main.php -os windows # Windows .exe +# Android APK(需 NDK + JDK + Android SDK) +set ANDROID_NDK=C:\Android\ndk\27.0.12077973 +php tphp.php ui.php -os android # 默认编译全部 ABI,生成 -debug.apk +php tphp.php ui.php -os android -o myapp # 生成 myapp-debug.apk + # 动态库导出(配合 #[Export] 注解) php tphp.php lib.php -shared -o mylib.dll # Windows php tphp.php lib.php -shared -o mylib.so # Linux @@ -44,12 +49,21 @@ php tphp.php lib.php -shared -o mylib.so # Linux |---|---| | `-o ` | 输出文件路径 | | `-cc ` | 指定 C 编译器(默认内置 TCC) | -| `-os ` | 跨编译目标:`windows`、`linux`、`macos` | -| `-arch ` | 目标架构:`x86_64`、`aarch64`(Windows/Linux 默认 x86_64,macOS 默认 aarch64) | +| `-os ` | 跨编译目标:`windows`、`linux`、`macos`、`android` | +| `-arch ` | 目标架构:`x86_64`、`aarch64`(Windows/Linux 默认 x86_64,macOS 默认 aarch64);Android 默认编译全部 ABI(arm64-v8a/x86_64/armeabi-v7a/x86) | | `-shared` | 编译为动态库(`.dll`/`.so`/`.dylib`),配合 `#[Export]` 导出 C 函数 | | `--debug` | 打印编译命令,运行二进制并与 `#debug` 预期输出逐行比对 | | `-h, --help` | 显示帮助 | +**Android 环境变量**: + +| 变量 | 说明 | +|---|---| +| `ANDROID_NDK` | NDK 根目录路径(必需) | +| `JAVA_HOME` | JDK 17/21 路径(APK 打包必需,Java 24+ 不兼容 Gradle 8.9) | +| `ANDROID_HOME` | Android SDK 路径(APK 打包必需) | +| `TPHP_ANDROID_API` | 目标 API 级别(默认 24 = Android 7.0) | + ### 测试与调试(`#debug`) 在源码任意位置插入 `#debug` 指令声明预期输出,配合 `--debug` 自动编译运行并比对: @@ -173,6 +187,7 @@ use MyApp\Models\User; | `include/require` | 没有运行时文件加载 | `#include` 引入 C 头文件,或多文件编译 | | `__call` `__get` `__set` | 没有运行时分发 | 显式定义方法,或用 `switch` 在单个方法内分发 | | `$obj->{$method}()` | 编译时不知道方法名 | 回调 map:`$fn = $map[$name]; $fn($args);` | +| `$obj->$var` 动态属性名 | 编译时不知道属性名,与 `$$var` 同理 | 用 `get_object_vars($obj)` 转数组后 `$arr[$key]` 访问,或直接用关联数组 | ### ⬜ 不做(权衡决定) @@ -204,6 +219,17 @@ use MyApp\Models\User; 已实现 **450+ 个**内置函数,覆盖 PHP 标准库的常用子集,覆盖数组/字符串/数学/时间/JSON/哈希/password(bcrypt)/进程控制/CSPRNG/ctype/正则表达式(PCRE NFA VM)/字符集转换(iconv)/过滤器(filter_var)/多线程(Thread/Mutex/CondVar/WaitGroup)/异步与协程(Channel/Future/chan_select,参考 vlang CSP 模型)/zlib(gzip 压缩+流式+增量上下文)/zip(归档读写)/stream(socket stream)/curl(HTTP/HTTPS 客户端,690 常量+35 函数)/pgsql(PostgreSQL 纯 C 协议,78 函数+60 常量)/pdo_pgsql(PostgreSQL PDO 驱动) 等。详见 [FUNCTIONS.md](FUNCTIONS.md)。 +### 📦 内置类 + +| 类 | 说明 | +|----|------| +| `stdClass` | PHP 原生动态属性容器,`new stdClass()` 创建空对象,支持字面量属性名读写、isset/unset、foreach、`(object)`/`(array)` 转换、`get_object_vars()`。基于 `t_array` 哈希索引实现 O(1) 属性查找。不支持 `$obj->$var` 动态属性名(AOT 约束) | +| `Exception` | 异常基类,`try/catch/finally` + `throw`,支持 `getMessage()`/`getCode()`/`getPrevious()` | +| `Generator` | 协程生成器,基于 minicoro,支持 `yield`/`send()`/`getReturn()` | +| `Resource` | 资源对象化根,`File` 子类替代 `fopen` resource | +| `Thread`/`Mutex`/`CondVar`/`WaitGroup` | 多线程 OOP API | +| `AnnotationEntry` | 注解系统条目类 | + ## 独有特性 ### AOT 类型固定 @@ -226,16 +252,17 @@ use MyApp\Models\User; | Thread-Local 运行时 | 每线程独立 str_pool/arr_pool/obj_pool | 多线程无锁竞争 | | 泛型数组紧凑存储 | `array` 按 T 紧凑存储 value | `array` 比 `array` 节省 67% 内存(8B vs 24B/元素) | -### 三编译器 + 四平台 +### 三编译器 + 五平台 -| | TCC | GCC | Clang | -|---|---|---|---| -| **Windows x86_64** | ✅ 默认内置 | ✅ | ✅ | -| **Linux x86_64** | ✅ | ✅ | ✅ | -| **Linux aarch64** | ✅ | ✅ | ✅ | -| **macOS aarch64** | ✅ | ✅ | ✅ | +| | TCC | GCC | Clang | NDK Clang | +|---|---|---|---|---| +| **Windows x86_64** | ✅ 默认内置 | ✅ | ✅ | — | +| **Linux x86_64** | ✅ | ✅ | ✅ | — | +| **Linux aarch64** | ✅ | ✅ | ✅ | — | +| **macOS aarch64** | ✅ | ✅ | ✅ | — | +| **Android (arm64-v8a/x86_64/armeabi-v7a/x86)** | — | — | — | ✅ | -TCC 亚秒编译,GCC/Clang -O2 带来 3-10 倍额外提速。`compat.h` 统一处理三编译器差异。 +TCC 亚秒编译,GCC/Clang -O2 带来 3-10 倍额外提速。Android 目标使用 NDK 工具链(`-os android`),编译为 `libtphp.so` 并通过 Gradle 打包为 APK。`compat.h` 统一处理三编译器差异。 ### 多线程支持 @@ -326,7 +353,7 @@ $idx = chan_select([$ch1, $ch2], 100); // 1 对标 PHP extension,`#import` 按需引入。已内置 `pcntl`、`posix`、`stream`、`openssl`、`curl`、`sqlite3`、`pdo`(SQLite+MySQL+PostgreSQL)、`pgsql`、`pdo_pgsql`、`zlib`、`zip`、`iconv`、`exif`、`fileinfo`、`calendar`、`filter`、`hash`、`gd`、`ui` 扩展: -- **ui** — 基于 sokol 的跨平台图形界面扩展(`#import ui`),提供 App/Window/Graphics 2D 绘图、Widget 控件体系(Button/Label/TextBox/CheckBox/Slider)、Stack/CanvasLayout 布局、事件系统和软键盘桥接。Windows/Linux 使用 OpenGL,macOS 使用 Metal。 +- **ui** — 基于 sokol 的跨平台图形界面扩展(`#import ui`),提供 App/Window/Graphics 2D 绘图、Widget 控件体系(Button/Label/TextBox/CheckBox/Slider)、Stack/CanvasLayout 布局、事件系统和软键盘桥接。Windows/Linux 使用 OpenGL,macOS 使用 Metal,Android 使用 GLES3。Android 平台支持 NDK 交叉编译并打包为 APK(`-os android`),包含 JNI 软键盘、触摸事件转换、原生按键事件拦截等移动端适配。 ```php 将 PHP UI 应用编译为 Android APK。基于 NDK Clang 工具链交叉编译为 `libtphp.so`,通过 Gradle 打包为 APK。 + +## 前置条件 + +### 1. Android NDK(必需) + +设置 `ANDROID_NDK` 环境变量指向 NDK 根目录: + +```bash +# Windows +set ANDROID_NDK=C:\Android\ndk\27.0.12077973 + +# macOS / Linux +export ANDROID_NDK=/opt/android-ndk +``` + +或通过 Android Studio SDK Manager 安装 NDK (Side by side)。 + +### 2. JDK 17 或 21(APK 打包必需) + +```bash +set JAVA_HOME=C:\Program Files\Java\jdk-17 +``` + +> ⚠️ **Java 版本兼容性**:Java 24+ 与 Gradle 8.9 / AGP 8.7.0 不兼容,会导致 AGP 内部 XML 解析器静默失败。tphp.php 会自动检测并搜索 Java 17/21 LTS 切换 `JAVA_HOME`,但建议直接安装 LTS 版本。 + +### 3. Android SDK(APK 打包必需) + +```bash +set ANDROID_HOME=C:\Users\\AppData\Local\Android\Sdk +``` + +需安装 `platforms;android-35` 和 `build-tools;35.0.0`。tphp.php 会自动检测并补写 SDK 34 许可哈希。 + +### 4. Gradle + +无需全局安装。`ext/ui/android/` 已包含 Gradle Wrapper(`gradlew`/`gradlew.bat` + `gradle/wrapper/`),构建时复制到工作目录自动使用。 + +## 一键构建 + +```bash +# 默认编译全部 4 种 ABI,生成 -debug.apk +php tphp.php ui_basic.php -os android + +# 指定输出名(生成 myapp-debug.apk) +php tphp.php ui_basic.php -os android -o myapp + +# 仅编译单 ABI(加速本地测试) +php tphp.php ui_basic.php -os android -arch x86_64 # 模拟器常用 +php tphp.php ui_basic.php -os android -arch aarch64 # 真机常用 +``` + +### 构建流程 + +1. **多 ABI 编译**(默认):通过子进程递归调用,为每个 ABI 编译 `libtphp.so`,输出到 `/build/android/jniLibs//` +2. **Gradle 打包**:复制 `ext/ui/android/` 工程模板到 `/build/android/`(模板保持洁净),调用 `gradlew assembleDebug` 打包 +3. **APK 输出**:Gradle 默认输出到 `build/android/app-debug.apk`,由 tphp.php 复制到当前工作目录,命名为 `-debug.apk`(遵循 `-o` 机制) + +> `ext/ui/android/` 仅作为工程模板,构建过程中不会写入任何产物,所有产物位于 `/build/android/`。 + +### 产物位置 + +| 产物 | 路径 | 说明 | +|------|------|------| +| `libtphp.so` | `/build/android/jniLibs//` | 各 ABI 共享库(供 Gradle 打包) | +| `-debug.apk` | `/` | 最终 APK(与其他二进制产物位置一致) | + +## 安装到设备 + +```bash +# 通过 adb 安装 +adb install ui_basic-debug.apk + +# 查看应用日志(PHP echo 输出已重定向到 logcat) +adb logcat -s tphp +``` + +## 环境变量 + +| 变量 | 必需 | 说明 | +|------|------|------| +| `ANDROID_NDK` | ✅ | NDK 根目录路径 | +| `JAVA_HOME` | APK 打包 | JDK 17/21 路径(Java 24+ 不兼容) | +| `ANDROID_HOME` | APK 打包 | Android SDK 路径 | +| `TPHP_ANDROID_API` | 否 | 目标 API 级别(默认 24 = Android 7.0) | + +> NDK 缺失时编译阶段直接退出并提示安装配置;SDK 或 JAVA_HOME 缺失时跳过 APK 打包但仍生成 `libtphp.so`。 + +## 支持的 ABI + +默认编译全部 4 种 ABI,覆盖真机与模拟器: + +| `-arch` 值 | ABI | 典型场景 | +|----------|-----|---------| +| `aarch64` | arm64-v8a | 64 位真机 | +| `x86_64` | x86_64 | 64 位模拟器 | +| `armv7a` | armeabi-v7a | 32 位真机 | +| `i686` | x86 | 32 位模拟器 | + +> 不指定 `-arch` 时默认编译全部 4 种 ABI。模拟器秒退通常是 ABI 不匹配导致(模拟器多为 x86_64,只编译 aarch64 时无匹配 `.so`)。 + +## 自定义 API Level + +默认 API level 为 24(Android 7.0)。可通过环境变量修改: + +```bash +set TPHP_ANDROID_API=30 # Android 11 +``` + +## 平台适配说明 + +### 屏幕方向 + +`AndroidManifest.xml` 中 `android:screenOrientation` 默认为 `portrait`(竖屏)。如需横屏,修改 `ext/ui/android/app/src/main/AndroidManifest.xml` 第 15 行。 + +### 触摸事件 + +sokol 在 Android 上生成 `TOUCHES_BEGAN/ENDED/MOVED` 事件,`ui.h` 中自动将首个触摸点转换为 `MOUSE_DOWN/UP/MOVE` 事件,桌面端 PHP 代码无需修改即可响应触摸。 + +### 按键输入 + +sokol 默认只处理 BACK 键,其他按键被丢弃。`ui.h` 通过 `native_event_cb` 钩子拦截 `AInputEvent`,映射 `AKEYCODE_*` 到 PHP Key 枚举的 ASCII 值,并对可打印字符(ASCII 32-126)额外生成 `CHAR` 事件驱动 TextBox 输入。 + +### 软键盘 + +`SoftInput::show()` / `SoftInput::hide()` 通过 JNI 调用 Android `InputMethodManager` 显示/隐藏软键盘。点击 TextBox 等可聚焦控件时会自动弹出。 + +### 调试输出 + +PHP `echo` / `var_dump` 等标准输出已通过 pipe + 后台线程重定向到 Android logcat(tag: `tphp`)。用 `adb logcat -s tphp` 查看。 + +## .gitignore + +`local.properties`(包含开发者个人 SDK 路径)已在 `.gitignore` 中排除,不要提交。 diff --git a/ext/ui/android/app/build.gradle b/ext/ui/android/app/build.gradle new file mode 100644 index 0000000..bfcbc09 --- /dev/null +++ b/ext/ui/android/app/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.tphp.ui' + compileSdk 35 + buildToolsVersion "35.0.0" + + defaultConfig { + applicationId "com.tphp.ui" + minSdk 24 + targetSdk 35 + versionCode 1 + versionName "1.0" + + ndk { + abiFilters 'arm64-v8a', 'x86_64', 'armeabi-v7a', 'x86' + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + sourceSets { + main { + // jniLibs 目录:优先用 -PtphpJniLibs= 传入(tphp.php 指向 cwd/build/android/jniLibs), + // 未传时回退到工程内的 src/main/jniLibs + jniLibs.srcDirs = [project.hasProperty('tphpJniLibs') + ? project.property('tphpJniLibs') + : 'src/main/jniLibs'] + } + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.6.1' +} diff --git a/ext/ui/android/app/proguard-rules.pro b/ext/ui/android/app/proguard-rules.pro new file mode 100644 index 0000000..104b344 --- /dev/null +++ b/ext/ui/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# No ProGuard rules needed for NativeActivity diff --git a/ext/ui/android/app/src/main/AndroidManifest.xml b/ext/ui/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c5408e7 --- /dev/null +++ b/ext/ui/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + diff --git a/ext/ui/android/app/src/main/java/com/tphp/ui/MainActivity.java b/ext/ui/android/app/src/main/java/com/tphp/ui/MainActivity.java new file mode 100644 index 0000000..f54bbff --- /dev/null +++ b/ext/ui/android/app/src/main/java/com/tphp/ui/MainActivity.java @@ -0,0 +1,11 @@ +package com.tphp.ui; + +import android.app.NativeActivity; + +/** + * TinyPHP UI 的 NativeActivity 入口。 + * sokol_app.h 的 ANativeActivity_onCreate 会在此 Activity 的 nativeWindow 上运行。 + * 编译产物 libapp.so 需放在 jniLibs// 下。 + */ +public class MainActivity extends NativeActivity { +} diff --git a/ext/ui/android/build.gradle b/ext/ui/android/build.gradle new file mode 100644 index 0000000..03a27ac --- /dev/null +++ b/ext/ui/android/build.gradle @@ -0,0 +1,4 @@ +// Top-level build file +plugins { + id 'com.android.application' version '8.7.0' apply false +} diff --git a/ext/ui/android/gradle.properties b/ext/ui/android/gradle.properties new file mode 100644 index 0000000..93c54b0 --- /dev/null +++ b/ext/ui/android/gradle.properties @@ -0,0 +1,4 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +org.gradle.jvmargs=-Xmx2048m +android.builder.sdkDownload=false diff --git a/ext/ui/android/gradle/wrapper/gradle-wrapper.jar b/ext/ui/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/ext/ui/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ext/ui/android/gradle/wrapper/gradle-wrapper.properties b/ext/ui/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..06c32f6 --- /dev/null +++ b/ext/ui/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/ext/ui/android/gradlew b/ext/ui/android/gradlew new file mode 100644 index 0000000..315094c --- /dev/null +++ b/ext/ui/android/gradlew @@ -0,0 +1,224 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# non-compliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced functionality shells use targetting, +# we will add a warning here. +# +# (2) You need a Java installation to run Gradle. Either install it on your +# system, or set the JAVA_HOME environment variable to point to a Java +# installation. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D... appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/ext/ui/android/gradlew.bat b/ext/ui/android/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/ext/ui/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ext/ui/android/settings.gradle b/ext/ui/android/settings.gradle new file mode 100644 index 0000000..d243252 --- /dev/null +++ b/ext/ui/android/settings.gradle @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + // 国内镜像优先(解决 Google/Maven Central 访问慢或 SSL 问题) + maven { url 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public/' } + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/public' } + maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven { url 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public/' } + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/public' } + google() + mavenCentral() + } +} +rootProject.name = "tphp-ui" +include ':app' diff --git a/ext/ui/sokol/README.md b/ext/ui/sokol/README.md index 9b2f111..f36c83a 100644 --- a/ext/ui/sokol/README.md +++ b/ext/ui/sokol/README.md @@ -13,12 +13,21 @@ | sokol_log.h | 日志回调(sokol 函数错误输出) | | sokol_time.h | 高精度计时(帧时间测量) | +## 平台后端 + +| 平台 | sokol 后端 | 说明 | +|------|-----------|------| +| Windows | `SOKOL_GLCORE` | GL 3.3 Core(TCC 缺 `windowsx.h`,用 OpenGL 而非 D3D11) | +| Linux | `SOKOL_GLCORE` | GL 3.3 Core | +| macOS | `SOKOL_METAL` | Metal | +| Android | `SOKOL_GLES3` + `SOKOL_NO_ENTRY` | GLES 3.0(NativeActivity 模式,入口为 `sokol_main()` 而非 `main()`) | + ## 不包含的文件(及替代方案) | 原计划文件 | 状态 | 替代方案 | |-----------|------|---------| | sokol_sgl.h / sokol_gl.h | 仓库中不存在 | 直接用 sokol_gfx 实现 2D 绘图 | -| fontstash.h | 仓库不可访问 | 自定义位图字体渲染器(ui.h 内实现) | +| fontstash.h | 仓库不可访问 | 内置 font8x8 点阵字体表(ui.h 内实现,跨平台通用,含 Android) | ## 使用方式 diff --git a/ext/ui/src/ui.h b/ext/ui/src/ui.h index 37db8e5..95e20d9 100644 --- a/ext/ui/src/ui.h +++ b/ext/ui/src/ui.h @@ -84,17 +84,25 @@ static __attribute__((used)) UINT __stdcall _tphp_GetRawInputData_stub( #ifndef u_long typedef unsigned long u_long; #endif +#elif defined(__ANDROID__) + #define SOKOL_GLES3 #elif defined(__linux__) #define SOKOL_GLCORE #endif #define SOKOL_IMPL -#define SOKOL_NO_ENTRY +#if !defined(__ANDROID__) + #define SOKOL_NO_ENTRY +#endif // macOS: sokol_app.h #import , ObjC 头中 +null 方法声明 // 会被 types.h 的 #define null ((void *)0) 破坏(展开为 +((void*)0) 语法错误)。 // sokol 头中 null 仅出现在注释,无 C 标识符使用,undef 安全。 #if defined(__APPLE__) #undef null #endif +#if defined(__ANDROID__) + #include + #include +#endif #include "sokol_app.h" #include "sokol_gfx.h" #include "sokol_glue.h" @@ -141,6 +149,12 @@ typedef struct { static ui_state_t _ui_state; +#if defined(__ANDROID__) +static sapp_desc _ui_android_desc; +// 前向声明:CodeGenerator 在 Android 模式下生成(替代 main()) +int tphp_android_main(int argc, char* argv[]); +#endif + // ── 自定义 sokol 日志函数 ── // 替代 sokol_log.h 的 slog_func: // - panic 级别(log_level==0):输出到 stderr 后 tp_throw(可被 try-catch 捕获), @@ -168,6 +182,384 @@ static void _ui_slog_func(const char* tag, uint32_t log_level, uint32_t log_item } } +// ── sokol 后端 DrawDevice 实现 ── +// begin_pass/end_pass 复用 sokol pass 管理(sg_begin_pass/sg_end_pass/sg_commit); +// 形状和文字都通过即时模式顶点缓冲区实现(每帧收集顶点,end_pass 时统一绘制)。 +// 文字使用点阵字体(init 时由 GDI 生成),展开为 1×1 像素矩形顶点。 + +// ── 形状渲染器(即时模式)── +// 每帧收集顶点到内存缓冲区,end_pass 时上传到 GPU 并绘制 +// 文字也通过此缓冲区渲染(点阵字体展开为顶点),与形状一起在 GPU 上绘制, +// 避免 GDI + OpenGL 双缓冲冲突导致的闪屏。 +#define _SR_MAX_VERTICES 65536 +typedef struct { + float x, y; // 屏幕坐标 + float r, g, b, a; // 颜色 +} _sr_vertex_t; + +static struct { + sg_pipeline pip; + sg_buffer vbuf; + _sr_vertex_t verts[_SR_MAX_VERTICES]; + int count; + bool initialized; +} _sr; + +// ── 内置 8x8 点阵字体(font8x8,公共域)── +// 跨平台:不依赖 GDI/FreeType,桌面和 Android 通用。 +// 每个字符 8x8 像素,每行 1 字节(bit 0 = 最左列)。 +// draw_text 时每个亮点展开为 1×1 像素矩形(6 个顶点), +// 通过 _sr_push_vertex 加入形状缓冲区,与 fill_rect 等一起在 GPU 上绘制。 +#define _FONT_W 8 +#define _FONT_H 8 +// ASCII 32-126 共 95 个可打印字符;索引 0 = space(32) +static const uint8_t _font8x8[95][8] = { + {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, /* */ + {0x18,0x3C,0x3C,0x18,0x18,0x00,0x18,0x00}, /* ! */ + {0x36,0x36,0x00,0x00,0x00,0x00,0x00,0x00}, /* " */ + {0x36,0x36,0x7F,0x36,0x7F,0x36,0x36,0x00}, /* # */ + {0x0C,0x3E,0x03,0x1E,0x30,0x1F,0x0C,0x00}, /* $ */ + {0x00,0x63,0x33,0x18,0x0C,0x66,0x63,0x00}, /* % */ + {0x1C,0x36,0x1C,0x6E,0x3B,0x33,0x6E,0x00}, /* & */ + {0x06,0x06,0x03,0x00,0x00,0x00,0x00,0x00}, /* ' */ + {0x18,0x30,0x60,0x60,0x60,0x30,0x18,0x00}, /* ( */ + {0x0C,0x06,0x03,0x03,0x03,0x06,0x0C,0x00}, /* ) */ + {0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00}, /* * */ + {0x00,0x0C,0x0C,0x3F,0x0C,0x0C,0x00,0x00}, /* + */ + {0x00,0x00,0x00,0x00,0x00,0x0C,0x0C,0x06}, /* , */ + {0x00,0x00,0x00,0x3F,0x00,0x00,0x00,0x00}, /* - */ + {0x00,0x00,0x00,0x00,0x00,0x0C,0x0C,0x00}, /* . */ + {0x00,0x60,0x30,0x18,0x0C,0x06,0x03,0x00}, /* / */ + {0x3E,0x63,0x73,0x7B,0x6F,0x67,0x3E,0x00}, /* 0 */ + {0x0C,0x0E,0x0C,0x0C,0x0C,0x0C,0x3F,0x00}, /* 1 */ + {0x1E,0x33,0x30,0x1C,0x06,0x33,0x3F,0x00}, /* 2 */ + {0x1E,0x33,0x30,0x1C,0x30,0x33,0x1E,0x00}, /* 3 */ + {0x38,0x3C,0x36,0x33,0x7F,0x30,0x78,0x00}, /* 4 */ + {0x3F,0x03,0x1F,0x30,0x30,0x33,0x1E,0x00}, /* 5 */ + {0x1C,0x06,0x03,0x1F,0x33,0x33,0x1E,0x00}, /* 6 */ + {0x3F,0x33,0x30,0x18,0x0C,0x0C,0x0C,0x00}, /* 7 */ + {0x1E,0x33,0x33,0x1E,0x33,0x33,0x1E,0x00}, /* 8 */ + {0x1E,0x33,0x33,0x3E,0x30,0x18,0x0C,0x00}, /* 9 */ + {0x00,0x0C,0x0C,0x00,0x00,0x0C,0x0C,0x00}, /* : */ + {0x00,0x0C,0x0C,0x00,0x00,0x0C,0x0C,0x06}, /* ; */ + {0x18,0x0C,0x06,0x03,0x06,0x0C,0x18,0x00}, /* < */ + {0x00,0x00,0x3F,0x00,0x00,0x3F,0x00,0x00}, /* = */ + {0x06,0x0C,0x18,0x30,0x18,0x0C,0x06,0x00}, /* > */ + {0x1E,0x33,0x30,0x18,0x0C,0x00,0x0C,0x00}, /* ? */ + {0x3E,0x63,0x7B,0x7B,0x7B,0x03,0x1E,0x00}, /* @ */ + {0x0C,0x1E,0x33,0x33,0x3F,0x33,0x33,0x00}, /* A */ + {0x3F,0x66,0x66,0x3E,0x66,0x66,0x3F,0x00}, /* B */ + {0x3C,0x66,0x03,0x03,0x03,0x66,0x3C,0x00}, /* C */ + {0x1F,0x36,0x66,0x66,0x66,0x36,0x1F,0x00}, /* D */ + {0x7F,0x46,0x16,0x06,0x16,0x46,0x7F,0x00}, /* E */ + {0x7F,0x46,0x16,0x06,0x16,0x06,0x0F,0x00}, /* F */ + {0x3C,0x66,0x03,0x03,0x73,0x66,0x7C,0x00}, /* G */ + {0x33,0x33,0x33,0x3F,0x33,0x33,0x33,0x00}, /* H */ + {0x1E,0x0C,0x0C,0x0C,0x0C,0x0C,0x1E,0x00}, /* I */ + {0x78,0x30,0x30,0x30,0x33,0x33,0x1E,0x00}, /* J */ + {0x67,0x66,0x36,0x1E,0x36,0x66,0x67,0x00}, /* K */ + {0x0F,0x06,0x06,0x06,0x46,0x66,0x7F,0x00}, /* L */ + {0x63,0x77,0x7F,0x7F,0x6B,0x63,0x63,0x00}, /* M */ + {0x63,0x67,0x6F,0x7B,0x73,0x63,0x63,0x00}, /* N */ + {0x1C,0x36,0x63,0x63,0x63,0x36,0x1C,0x00}, /* O */ + {0x3F,0x66,0x66,0x3E,0x06,0x06,0x0F,0x00}, /* P */ + {0x1E,0x33,0x33,0x33,0x3B,0x1E,0x38,0x00}, /* Q */ + {0x3F,0x66,0x66,0x3E,0x36,0x66,0x67,0x00}, /* R */ + {0x1E,0x33,0x07,0x0E,0x38,0x33,0x1E,0x00}, /* S */ + {0x3F,0x2D,0x0C,0x0C,0x0C,0x0C,0x1E,0x00}, /* T */ + {0x33,0x33,0x33,0x33,0x33,0x33,0x3F,0x00}, /* U */ + {0x33,0x33,0x33,0x33,0x33,0x1E,0x0C,0x00}, /* V */ + {0x63,0x63,0x6B,0x7F,0x7F,0x77,0x63,0x00}, /* W */ + {0x63,0x36,0x1C,0x1C,0x1C,0x36,0x63,0x00}, /* X */ + {0x33,0x33,0x33,0x1E,0x0C,0x0C,0x1E,0x00}, /* Y */ + {0x7F,0x63,0x31,0x18,0x4C,0x66,0x7F,0x00}, /* Z */ + {0x1E,0x06,0x06,0x06,0x06,0x06,0x1E,0x00}, /* [ */ + {0x03,0x06,0x0C,0x18,0x30,0x60,0x40,0x00}, /* \ */ + {0x1E,0x18,0x18,0x18,0x18,0x18,0x1E,0x00}, /* ] */ + {0x08,0x1C,0x36,0x63,0x00,0x00,0x00,0x00}, /* ^ */ + {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF}, /* _ */ + {0x0C,0x0C,0x18,0x00,0x00,0x00,0x00,0x00}, /* ` */ + {0x00,0x00,0x3E,0x30,0x3E,0x33,0x3E,0x00}, /* a */ + {0x07,0x06,0x06,0x3E,0x66,0x66,0x3B,0x00}, /* b */ + {0x00,0x00,0x3E,0x03,0x03,0x33,0x1E,0x00}, /* c */ + {0x38,0x30,0x30,0x3E,0x33,0x33,0x3E,0x00}, /* d */ + {0x00,0x00,0x1E,0x33,0x3F,0x03,0x1E,0x00}, /* e */ + {0x1C,0x36,0x06,0x0F,0x06,0x06,0x0F,0x00}, /* f */ + {0x00,0x00,0x3E,0x33,0x33,0x3E,0x30,0x1F}, /* g */ + {0x07,0x06,0x06,0x3E,0x66,0x66,0x66,0x00}, /* h */ + {0x0C,0x00,0x0E,0x0C,0x0C,0x0C,0x1E,0x00}, /* i */ + {0x30,0x00,0x30,0x30,0x30,0x33,0x33,0x1E}, /* j */ + {0x07,0x06,0x66,0x36,0x1E,0x36,0x66,0x00}, /* k */ + {0x0E,0x0C,0x0C,0x0C,0x0C,0x0C,0x1E,0x00}, /* l */ + {0x00,0x00,0x33,0x7F,0x7F,0x6B,0x63,0x00}, /* m */ + {0x00,0x00,0x1F,0x33,0x33,0x33,0x33,0x00}, /* n */ + {0x00,0x00,0x1E,0x33,0x33,0x33,0x1E,0x00}, /* o */ + {0x00,0x00,0x3B,0x66,0x66,0x3E,0x06,0x0F}, /* p */ + {0x00,0x00,0x3E,0x33,0x33,0x3E,0x30,0x78}, /* q */ + {0x00,0x00,0x3B,0x66,0x06,0x06,0x0F,0x00}, /* r */ + {0x00,0x00,0x3E,0x03,0x1E,0x30,0x1E,0x00}, /* s */ + {0x06,0x06,0x0F,0x06,0x06,0x36,0x1C,0x00}, /* t */ + {0x00,0x00,0x33,0x33,0x33,0x33,0x3E,0x00}, /* u */ + {0x00,0x00,0x33,0x33,0x33,0x1E,0x0C,0x00}, /* v */ + {0x00,0x00,0x63,0x6B,0x7F,0x7F,0x36,0x00}, /* w */ + {0x00,0x00,0x63,0x36,0x1C,0x36,0x63,0x00}, /* x */ + {0x00,0x00,0x33,0x33,0x33,0x3E,0x30,0x1F}, /* y */ + {0x00,0x00,0x3F,0x19,0x0C,0x26,0x3F,0x00}, /* z */ + {0x38,0x0C,0x0C,0x07,0x0C,0x0C,0x38,0x00}, /* { */ + {0x00,0x0C,0x0C,0x0C,0x0C,0x0C,0x0C,0x00}, /* | */ + {0x07,0x18,0x18,0x70,0x18,0x18,0x07,0x00}, /* } */ + {0x6E,0x3B,0x00,0x00,0x00,0x00,0x00,0x00}, /* ~ */ +}; + +// 纯色 shader:顶点接收屏幕坐标+颜色,片段输出颜色 +// 桌面 GL 3.3 和 GLES 3.0 共用同一份 GLSL ES 3.0 源码 +#if defined(SOKOL_GLCORE) +static const char* _sr_vs_src = + "#version 330 core\n" + "layout(location=0) in vec2 pos;\n" + "layout(location=1) in vec4 color;\n" + "uniform vec2 u_resolution;\n" + "out vec4 v_color;\n" + "void main() {\n" + " vec2 ndc = (pos / u_resolution) * 2.0 - 1.0;\n" + " gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);\n" + " v_color = color;\n" + "}\n"; +static const char* _sr_fs_src = + "#version 330 core\n" + "in vec4 v_color;\n" + "out vec4 frag_color;\n" + "void main() { frag_color = v_color; }\n"; +#elif defined(SOKOL_GLES3) +static const char* _sr_vs_src = + "#version 300 es\n" + "precision highp float;\n" + "layout(location=0) in vec2 pos;\n" + "layout(location=1) in vec4 color;\n" + "uniform vec2 u_resolution;\n" + "out vec4 v_color;\n" + "void main() {\n" + " vec2 ndc = (pos / u_resolution) * 2.0 - 1.0;\n" + " gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);\n" + " v_color = color;\n" + "}\n"; +static const char* _sr_fs_src = + "#version 300 es\n" + "precision highp float;\n" + "in vec4 v_color;\n" + "out vec4 frag_color;\n" + "void main() { frag_color = v_color; }\n"; +#elif defined(SOKOL_METAL) +// Metal 后端需要 MSL(Metal Shading Language)源码,entry 函数名必须显式提供 +static const char* _sr_vs_src = + "#include \n" + "using namespace metal;\n" + "struct v_in { float2 pos [[attribute(0)]]; float4 color [[attribute(1)]]; };\n" + "struct v_out { float4 position [[position]]; float4 v_color; };\n" + "vertex v_out vs_main(v_in in [[stage_in]], constant float2& u_resolution [[buffer(0)]]) {\n" + " v_out out;\n" + " float2 ndc = (in.pos / u_resolution) * 2.0 - 1.0;\n" + " out.position = float4(ndc.x, -ndc.y, 0.0, 1.0);\n" + " out.v_color = in.color;\n" + " return out;\n" + "}\n"; +static const char* _sr_fs_src = + "#include \n" + "using namespace metal;\n" + "struct v_out { float4 position [[position]]; float4 v_color; };\n" + "fragment float4 fs_main(v_out in [[stage_in]]) {\n" + " return in.v_color;\n" + "}\n"; +#endif + +// Metal 后端需要显式 entry 函数名;GL 后端 entry 为 NULL(按 GLSL main 约定) +#if defined(SOKOL_METAL) + #define _SR_VS_ENTRY "vs_main" + #define _SR_FS_ENTRY "fs_main" +#else + #define _SR_VS_ENTRY NULL + #define _SR_FS_ENTRY NULL +#endif + +static void _sr_init(void) { + if (_sr.initialized) return; + _sr.vbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = sizeof(_sr_vertex_t) * _SR_MAX_VERTICES, + .usage = { .stream_update = true }, + }); + sg_shader shd = sg_make_shader(&(sg_shader_desc){ + .vertex_func = { .source = _sr_vs_src, .entry = _SR_VS_ENTRY }, + .fragment_func = { .source = _sr_fs_src, .entry = _SR_FS_ENTRY }, + .attrs = { + [0] = { .glsl_name = "pos", .hlsl_sem_name = "POS" }, + [1] = { .glsl_name = "color", .hlsl_sem_name = "COLOR" }, + }, + .uniform_blocks[0] = { + .stage = SG_SHADERSTAGE_VERTEX, + .size = sizeof(float) * 2, + .glsl_uniforms = { [0] = { .glsl_name = "u_resolution", .type = SG_UNIFORMTYPE_FLOAT2 } }, + }, + }); + _sr.pip = sg_make_pipeline(&(sg_pipeline_desc){ + .shader = shd, + .layout = { + .attrs = { + [0] = { .format = SG_VERTEXFORMAT_FLOAT2 }, + [1] = { .format = SG_VERTEXFORMAT_FLOAT4 }, + }, + }, + .colors[0] = { .blend = { + .enabled = true, + .src_factor_rgb = SG_BLENDFACTOR_SRC_ALPHA, + .dst_factor_rgb = SG_BLENDFACTOR_ONE_MINUS_SRC_ALPHA, + }}, + }); + _sr.initialized = true; +} + +static inline void _sr_push_vertex(float x, float y, float r, float g, float b, float a) { + if (_sr.count >= _SR_MAX_VERTICES) return; + _sr.verts[_sr.count].x = x; + _sr.verts[_sr.count].y = y; + _sr.verts[_sr.count].r = r; + _sr.verts[_sr.count].g = g; + _sr.verts[_sr.count].b = b; + _sr.verts[_sr.count].a = a; + _sr.count++; +} + +static inline void _sr_unpack_color(t_int rgba, float* r, float* g, float* b, float* a) { + *a = (float)((rgba >> 24) & 0xFF) / 255.0f; + *r = (float)((rgba >> 16) & 0xFF) / 255.0f; + *g = (float)((rgba >> 8) & 0xFF) / 255.0f; + *b = (float)(rgba & 0xFF) / 255.0f; +} + +static void _sr_flush(void) { + if (_sr.count == 0) return; + sg_update_buffer(_sr.vbuf, &(sg_range){ .ptr = _sr.verts, .size = (size_t)_sr.count * sizeof(_sr_vertex_t) }); + sg_apply_pipeline(_sr.pip); + sg_apply_bindings(&(sg_bindings){ .vertex_buffers[0] = _sr.vbuf }); + float resolution[2] = { (float)sapp_width(), (float)sapp_height() }; + sg_apply_uniforms(0, &(sg_range){ .ptr = resolution, .size = sizeof(resolution) }); + sg_draw(0, _sr.count, 1); + _sr.count = 0; +} + +static void _sokol_begin_pass(t_int rgba) { + float a = (float)((rgba >> 24) & 0xFF) / 255.0f; + float r = (float)((rgba >> 16) & 0xFF) / 255.0f; + float g = (float)((rgba >> 8) & 0xFF) / 255.0f; + float b = (float)(rgba & 0xFF) / 255.0f; + sg_pass_action pa = (sg_pass_action){ + .colors[0] = { .load_action = SG_LOADACTION_CLEAR, .clear_value = { r, g, b, a } } + }; + sg_begin_pass(&(sg_pass){ .action = pa, .swapchain = sglue_swapchain() }); + _sr.count = 0; +} + +static void _sokol_end_pass(void) { + _sr_flush(); + sg_end_pass(); + sg_commit(); +} + +static void _sokol_fill_rect(t_int x, t_int y, t_int w, t_int h, t_int rgba) { + float r, g, b, a; + _sr_unpack_color(rgba, &r, &g, &b, &a); + float x0 = (float)x, y0 = (float)y; + float x1 = (float)(x + w), y1 = (float)(y + h); + _sr_push_vertex(x0, y0, r, g, b, a); + _sr_push_vertex(x1, y0, r, g, b, a); + _sr_push_vertex(x1, y1, r, g, b, a); + _sr_push_vertex(x0, y0, r, g, b, a); + _sr_push_vertex(x1, y1, r, g, b, a); + _sr_push_vertex(x0, y1, r, g, b, a); +} + +static void _sokol_draw_text(t_int x, t_int y, const char* text, int len, t_int rgba) { + if (!text || len <= 0) return; + float r, g, b, a; + _sr_unpack_color(rgba, &r, &g, &b, &a); + for (int i = 0; i < len; i++) { + uint8_t c = (uint8_t)text[i]; + // 不可打印字符或超出范围用 '?' 替代 + if (c < 32 || c > 126) c = '?'; + const uint8_t* bits = _font8x8[c - 32]; + float fx0 = (float)(x + i * _FONT_W); + float fy0 = (float)y; + for (int py = 0; py < _FONT_H; py++) { + uint8_t row = bits[py]; + if (!row) continue; // 空行跳过 + for (int px = 0; px < _FONT_W; px++) { + if (row & (1 << px)) { + float vx = fx0 + px; + float vy = fy0 + py; + // 1×1 像素矩形(2 个三角形 = 6 个顶点) + _sr_push_vertex(vx, vy, r, g, b, a); + _sr_push_vertex(vx + 1, vy, r, g, b, a); + _sr_push_vertex(vx + 1, vy + 1, r, g, b, a); + _sr_push_vertex(vx, vy, r, g, b, a); + _sr_push_vertex(vx + 1, vy + 1, r, g, b, a); + _sr_push_vertex(vx, vy + 1, r, g, b, a); + } + } + } + } +} + +static void _sokol_draw_line(t_int x1, t_int y1, t_int x2, t_int y2, t_int rgba) { + float r, g, b, a; + _sr_unpack_color(rgba, &r, &g, &b, &a); + float dx = (float)(x2 - x1), dy = (float)(y2 - y1); + float len2 = dx * dx + dy * dy; + if (len2 < 0.001f) return; + float inv_len = 1.0f / sqrtf(len2); + float nx = -dy * inv_len * 0.5f; + float ny = dx * inv_len * 0.5f; + float p0x = (float)x1 + nx, p0y = (float)y1 + ny; + float p1x = (float)x1 - nx, p1y = (float)y1 - ny; + float p2x = (float)x2 - nx, p2y = (float)y2 - ny; + float p3x = (float)x2 + nx, p3y = (float)y2 + ny; + _sr_push_vertex(p0x, p0y, r, g, b, a); + _sr_push_vertex(p1x, p1y, r, g, b, a); + _sr_push_vertex(p2x, p2y, r, g, b, a); + _sr_push_vertex(p0x, p0y, r, g, b, a); + _sr_push_vertex(p2x, p2y, r, g, b, a); + _sr_push_vertex(p3x, p3y, r, g, b, a); +} + +static void _sokol_draw_rect(t_int x, t_int y, t_int w, t_int h, t_int rgba) { + _sokol_draw_line(x, y, x + w, y, rgba); + _sokol_draw_line(x + w, y, x + w, y + h, rgba); + _sokol_draw_line(x + w, y + h, x, y + h, rgba); + _sokol_draw_line(x, y + h, x, y, rgba); +} + +static void _sokol_draw_circle(t_int cx, t_int cy, t_int r, t_int rgba) { + float cr, cg, cb, ca; + _sr_unpack_color(rgba, &cr, &cg, &cb, &ca); + float fcx = (float)cx, fcy = (float)cy, fr = (float)r; + int segments = 32; + for (int i = 0; i < segments; i++) { + float a0 = (float)i * 2.0f * 3.14159265f / segments; + float a1 = (float)(i + 1) * 2.0f * 3.14159265f / segments; + _sr_push_vertex(fcx, fcy, cr, cg, cb, ca); + _sr_push_vertex(fcx + cosf(a0) * fr, fcy + sinf(a0) * fr, cr, cg, cb, ca); + _sr_push_vertex(fcx + cosf(a1) * fr, fcy + sinf(a1) * fr, cr, cg, cb, ca); + } +} + +static ui_draw_device_t _ui_sokol_device = { + .begin_pass = _sokol_begin_pass, + .end_pass = _sokol_end_pass, + .fill_rect = _sokol_fill_rect, + .draw_text = _sokol_draw_text, + .draw_line = _sokol_draw_line, + .draw_rect = _sokol_draw_rect, + .draw_circle = _sokol_draw_circle, +}; + // ── sokol 回调桥(C → PHP 回调)── // 所有回调包裹 TP_TRY/TP_CATCH_ANY: // PHP 回调内 tp_throw 触发 longjmp,若不在此捕获会跳过 sokol 事件循环, @@ -177,6 +569,7 @@ static void _ui_sokol_init_cb(void) { .environment = sglue_environment(), .logger = { .func = _ui_slog_func }, }); + _sr_init(); // 初始化形状渲染器 _ui_state.initialized = true; _ui_state.in_frame = false; _ui_state.pass_active = false; @@ -210,6 +603,7 @@ static void _ui_sokol_frame_cb(void) { // 确保 pass 正确收尾(即使 PHP 回调中途抛异常也不能漏掉 sg_end_pass+sg_commit, // 否则 sokol 状态不一致,下一帧渲染崩溃) if (_ui_state.pass_active) { + _sr_flush(); // 绘制所有收集的形状和文字(必须在 sg_end_pass 之前) sg_end_pass(); sg_commit(); _ui_state.pass_active = false; @@ -218,10 +612,41 @@ static void _ui_sokol_frame_cb(void) { } static void _ui_sokol_event_cb(const sapp_event* ev) { + // Android: 将触摸事件转换为鼠标事件(PHP 侧统一用 MouseDown/Up/Move 处理) + // sokol 在 Android 上只生成 TOUCHES_BEGAN/MOVED/ENDED,不设置 mouse_x/mouse_y + const sapp_event* pass_ev = ev; + sapp_event converted; +#if defined(__ANDROID__) + if (ev->num_touches > 0) { + float tx = ev->touches[0].pos_x; + float ty = ev->touches[0].pos_y; + if (ev->type == SAPP_EVENTTYPE_TOUCHES_BEGAN) { + converted = *ev; + converted.type = SAPP_EVENTTYPE_MOUSE_DOWN; + converted.mouse_button = SAPP_MOUSEBUTTON_LEFT; + converted.mouse_x = tx; + converted.mouse_y = ty; + pass_ev = &converted; + } else if (ev->type == SAPP_EVENTTYPE_TOUCHES_ENDED) { + converted = *ev; + converted.type = SAPP_EVENTTYPE_MOUSE_UP; + converted.mouse_button = SAPP_MOUSEBUTTON_LEFT; + converted.mouse_x = tx; + converted.mouse_y = ty; + pass_ev = &converted; + } else if (ev->type == SAPP_EVENTTYPE_TOUCHES_MOVED) { + converted = *ev; + converted.type = SAPP_EVENTTYPE_MOUSE_MOVE; + converted.mouse_x = tx; + converted.mouse_y = ty; + pass_ev = &converted; + } + } +#endif if (_ui_state.on_event_cb.func) { TP_TRY { // 传递 sapp_event 指针给 PHP 侧(以 t_int 形式,PHP 侧用 Event::fromPtr 解析) - ((void(*)(t_int, void*))_ui_state.on_event_cb.func)((t_int)(intptr_t)ev, _ui_state.on_event_cb.env); + ((void(*)(t_int, void*))_ui_state.on_event_cb.func)((t_int)(intptr_t)pass_ev, _ui_state.on_event_cb.env); } TP_CATCH_ANY(_tp_msg) { fflush(stdout); fprintf(stderr, "\nFatal error: Uncaught exception in event callback: %s\n\n", @@ -232,71 +657,116 @@ static void _ui_sokol_event_cb(const sapp_event* ev) { } } -static void _ui_sokol_cleanup_cb(void) { - sg_shutdown(); - _ui_state.initialized = false; -} - -// ── 包含 CPU 软件渲染后端 ── -// 必须在此处包含:ui_cpu.h 依赖上文的 _ui_state、sapp_event、ui_draw_device_t、 -// TP_TRY/tp_throw 等定义;其定义的 _ui_cpu_device / _cpu_app_run 供下文 ui_app_run -// 的后端选择与窗口/绘图分派使用。 -#include "ui_cpu.h" - -// ── sokol 后端 DrawDevice 实现 ── -// begin_pass/end_pass 复用 sokol pass 管理(sg_begin_pass/sg_end_pass/sg_commit); -// 形状绘制(fill_rect 等)在 GPU 后端尚未实现,抛异常提示。 -// 无 GPU 环境下 ui_app_run 会自动回退到 CPU 后端,不会走到这些函数。 -static void _sokol_begin_pass(t_int rgba) { - float a = (float)((rgba >> 24) & 0xFF) / 255.0f; - float r = (float)((rgba >> 16) & 0xFF) / 255.0f; - float g = (float)((rgba >> 8) & 0xFF) / 255.0f; - float b = (float)(rgba & 0xFF) / 255.0f; - sg_pass_action pa = (sg_pass_action){ - .colors[0] = { .load_action = SG_LOADACTION_CLEAR, .clear_value = { r, g, b, a } } - }; - sg_begin_pass(&(sg_pass){ .action = pa, .swapchain = sglue_swapchain() }); +// ── Android 原生按键事件拦截 ── +// sokol_app.h 的 _sapp_android_key_event 只处理 BACK 键,其他按键直接丢弃, +// 导致 Android 上无 KEY_DOWN/KEY_UP/CHAR 事件,TextBox 无法接收输入。 +// 通过 native_event_cb 钩子拦截 AInputEvent,映射 AKEYCODE_* → ASCII/Key 枚举值, +// 构造 sapp_event 并调用 _ui_sokol_event_cb 分发给 PHP 侧。 +// key_code 使用 PHP Key 枚举的 ASCII 值(与桌面端 SAPP_KEYCODE 字母数字段一致, +// 控制键段则与 Key 枚举对齐),确保 PHP 侧 Key::tryFrom($code) 能正确匹配。 +#if defined(__ANDROID__) +static int _ui_android_map_keycode(int32_t keycode) { + // 字母键 AKEYCODE_A(29)..AKEYCODE_Z(54) → 'A'(65)..'Z'(90) + if (keycode >= 29 /*AKEYCODE_A*/ && keycode <= 54 /*AKEYCODE_Z*/) { + return 65 + (keycode - 29); + } + // 数字键 AKEYCODE_0(7)..AKEYCODE_9(16) → '0'(48)..'9'(57) + if (keycode >= 7 /*AKEYCODE_0*/ && keycode <= 16 /*AKEYCODE_9*/) { + return 48 + (keycode - 7); + } + switch (keycode) { + case 62 /*AKEYCODE_SPACE*/: return 32; // Key::Space + case 66 /*AKEYCODE_ENTER*/: return 13; // Key::Enter + case 67 /*AKEYCODE_DEL*/: return 8; // Key::Backspace + case 61 /*AKEYCODE_TAB*/: return 9; // Key::Tab + case 111 /*AKEYCODE_ESCAPE*/: return 27; // Key::Escape + case 21 /*AKEYCODE_DPAD_LEFT*/: return 37; // Key::Left + case 22 /*AKEYCODE_DPAD_RIGHT*/: return 39; // Key::Right + case 19 /*AKEYCODE_DPAD_UP*/: return 38; // Key::Up + case 20 /*AKEYCODE_DPAD_DOWN*/: return 40; // Key::Down + case 122 /*AKEYCODE_HOME*/: return 36; // Key::Home + // 注意:AKEYCODE_FORWARD_DEL(112) 不映射,因其会与 AKEYCODE_PERIOD(56) 冲突 + // (两者都对应 ASCII 46 '.');Key::Delete=46 本身也与 '.' 冲突,属枚举设计问题 + case 55 /*AKEYCODE_COMMA*/: return 44; // ',' + case 56 /*AKEYCODE_PERIOD*/: return 46; // '.' + case 69 /*AKEYCODE_MINUS*/: return 45; // '-' + case 70 /*AKEYCODE_EQUALS*/: return 61; // '=' + case 71 /*AKEYCODE_LEFT_BRACKET*/: return 91; // '[' + case 72 /*AKEYCODE_RIGHT_BRACKET*/: return 93;// ']' + case 73 /*AKEYCODE_BACKSLASH*/: return 92; // '\' + case 74 /*AKEYCODE_SEMICOLON*/: return 59; // ';' + case 75 /*AKEYCODE_APOSTROPHE*/: return 39; // '\'' + case 76 /*AKEYCODE_SLASH*/: return 47; // '/' + case 68 /*AKEYCODE_GRAVE*/: return 96; // '`' + default: return 0; // 未映射 + } } -static void _sokol_end_pass(void) { - sg_end_pass(); - sg_commit(); -} +static bool _ui_android_native_event_cb(const void* native_event) { + const AInputEvent* e = (const AInputEvent*)native_event; + if (AInputEvent_getType(e) != AINPUT_EVENT_TYPE_KEY /* =1 */) { + return false; // 非按键事件(如触摸),交给 sokol 处理 + } + int32_t keycode = AKeyEvent_getKeyCode(e); + // BACK 键不拦截,让 sokol 默认处理(触发 shutdown) + if (keycode == 4 /*AKEYCODE_BACK*/) { + return false; + } + int mapped = _ui_android_map_keycode(keycode); + if (mapped == 0) { + return false; // 未映射的键,交给 sokol + } + int32_t action = AKeyEvent_getAction(e); + int32_t mods = 0; + int32_t metastate = AKeyEvent_getMetaState(e); + if (metastate & (0x1 | 0x40 | 0x80)) mods |= 1; // AMETA_SHIFT_ON|SHIFT_LEFT_ON|SHIFT_RIGHT_ON → SAPP_MODIFIER_SHIFT + if (metastate & (0x1000 | 0x2000 | 0x4000)) mods |= 2; // AMETA_CTRL_ON|CTRL_LEFT_ON|CTRL_RIGHT_ON → SAPP_MODIFIER_CTRL + if (metastate & (0x2 | 0x10 | 0x20)) mods |= 4; // AMETA_ALT_ON|ALT_LEFT_ON|ALT_RIGHT_ON → SAPP_MODIFIER_ALT -static void _sokol_fill_rect(t_int x, t_int y, t_int w, t_int h, t_int rgba) { - (void)x; (void)y; (void)w; (void)h; (void)rgba; - tp_throw("fill_rect not yet implemented on GPU backend (auto-fallback to CPU in non-GPU environments)"); -} + sapp_event ev; + memset(&ev, 0, sizeof(ev)); + ev.frame_count = sapp_frame_count(); + ev.modifiers = (uint32_t)mods; -static void _sokol_draw_text(t_int x, t_int y, const char* text, int len, t_int rgba) { - (void)x; (void)y; (void)text; (void)len; (void)rgba; - tp_throw("draw_text not yet implemented on GPU backend (auto-fallback to CPU in non-GPU environments)"); -} + if (action == 0 /*AKEY_EVENT_ACTION_DOWN*/) { + ev.type = SAPP_EVENTTYPE_KEY_DOWN; + ev.key_code = (sapp_keycode)mapped; + _ui_sokol_event_cb(&ev); + // 对可打印字符(ASCII 32..126)额外生成 CHAR 事件,驱动 TextBox 文本输入 + if (mapped >= 32 && mapped <= 126) { + ev.type = SAPP_EVENTTYPE_CHAR; + ev.char_code = (uint32_t)mapped; + _ui_sokol_event_cb(&ev); + } + } else if (action == 1 /*AKEY_EVENT_ACTION_UP*/) { + ev.type = SAPP_EVENTTYPE_KEY_UP; + ev.key_code = (sapp_keycode)mapped; + _ui_sokol_event_cb(&ev); + } + // AKEY_EVENT_ACTION_MULTIPLE(值=2)忽略(重复事件,避免双重输入) -static void _sokol_draw_line(t_int x1, t_int y1, t_int x2, t_int y2, t_int rgba) { - (void)x1; (void)y1; (void)x2; (void)y2; (void)rgba; - tp_throw("draw_line not yet implemented on GPU backend (auto-fallback to CPU in non-GPU environments)"); + return true; // 已处理,阻止 sokol 默认处理 } +#endif -static void _sokol_draw_rect(t_int x, t_int y, t_int w, t_int h, t_int rgba) { - (void)x; (void)y; (void)w; (void)h; (void)rgba; - tp_throw("draw_rect not yet implemented on GPU backend (auto-fallback to CPU in non-GPU environments)"); -} +// 前向声明:_ui_softinput_cb 在后面定义,但 cleanup_cb 需要引用 +static t_callback _ui_softinput_cb; -static void _sokol_draw_circle(t_int cx, t_int cy, t_int r, t_int rgba) { - (void)cx; (void)cy; (void)r; (void)rgba; - tp_throw("draw_circle not yet implemented on GPU backend (auto-fallback to CPU in non-GPU environments)"); +static void _ui_sokol_cleanup_cb(void) { + sg_shutdown(); + _ui_state.initialized = false; + // unpin PHP 回调 env,防止内存泄漏 + if (_ui_state.on_init_cb.env) { tphp_fn_phpc_env_unpin(_ui_state.on_init_cb.env); _ui_state.on_init_cb.env = NULL; } + if (_ui_state.on_frame_cb.env) { tphp_fn_phpc_env_unpin(_ui_state.on_frame_cb.env); _ui_state.on_frame_cb.env = NULL; } + if (_ui_state.on_event_cb.env) { tphp_fn_phpc_env_unpin(_ui_state.on_event_cb.env); _ui_state.on_event_cb.env = NULL; } + if (_ui_softinput_cb.env) { tphp_fn_phpc_env_unpin(_ui_softinput_cb.env); _ui_softinput_cb.env = NULL; } } -static ui_draw_device_t _ui_sokol_device = { - .begin_pass = _sokol_begin_pass, - .end_pass = _sokol_end_pass, - .fill_rect = _sokol_fill_rect, - .draw_text = _sokol_draw_text, - .draw_line = _sokol_draw_line, - .draw_rect = _sokol_draw_rect, - .draw_circle = _sokol_draw_circle, -}; +// ── 包含 CPU 软件渲染后端 ── +// 必须在此处包含:ui_cpu.h 依赖上文的 _ui_state、sapp_event、ui_draw_device_t、 +// TP_TRY/tp_throw 等定义;其定义的 _ui_cpu_device / _cpu_app_run 供下文 ui_app_run +// 的后端选择与窗口/绘图分派使用。 +#include "ui_cpu.h" // ── ui_* C 函数(通过 function C.xxx(...): C.ret; 声明 + C->xxx() 调用)── @@ -318,7 +788,9 @@ static ui_draw_device_t _ui_sokol_device = { // ui_app_run 的 TP_CATCH_ANY 兜底,通过 _ui_destroy_sokol_orphans 清理孤儿窗口后 // 回退 CPU 后端。 static inline bool _ui_probe_gpu_available(void) { -#if defined(_WIN32) || defined(_WIN64) +#if defined(__ANDROID__) + return true; // GLES 总是可用 +#elif defined(_WIN32) || defined(_WIN64) // WGL 扩展常量(避免依赖 wglext.h) #define _TPHP_WGL_NUMBER_PIXEL_FORMATS_ARB 0x2000 #define _TPHP_WGL_DRAW_TO_WINDOW_ARB 0x2001 @@ -436,6 +908,8 @@ static inline void _ui_destroy_sokol_orphans(void) { #if defined(_WIN32) || defined(_WIN64) EnumWindows(_ui_destroy_sokol_enumproc, 0); UnregisterClassW(L"SOKOLAPP", GetModuleHandleW(NULL)); +#elif defined(__ANDROID__) + // Android: EGL 资源由 sokol 内部管理,无需手动清理 #endif } @@ -479,6 +953,17 @@ static inline t_int ui_app_run(t_int width, t_int height, t_string title) { // 第二层:sokol panic 兜底。预探测用临时窗口 DC,sokol 用主窗口 DC,某些环境 // (如 RDP)下两者结果不同,预探测可能误判。sapp_run 的 panic 被 TP_CATCH_ANY // 捕获后,先销毁所有 sokol 孤儿窗口(msg_hwnd + 主窗口),再回退 CPU 后端。 +#if defined(__ANDROID__) + // Android: 不调用 sapp_run,填充全局 desc 供 sokol_main() 返回 + // GLES3 后端:版本必须是 3.0(不能用桌面 GL 3.3,否则 eglCreateContext 失败秒退) + desc.gl.major_version = 3; + desc.gl.minor_version = 0; + // 拦截 Android 原生按键事件,生成 KEY_DOWN/KEY_UP/CHAR 事件 + // (sokol 默认只处理 BACK 键,其他按键直接丢弃,导致 TextBox 无法输入) + desc.android.native_event_cb = _ui_android_native_event_cb; + _ui_android_desc = desc; + // Android 上事件循环由 ANativeActivity 驱动 +#else TP_TRY { sapp_run(&desc); } TP_CATCH_ANY(_tp_msg) { @@ -492,9 +977,78 @@ static inline t_int ui_app_run(t_int width, t_int height, t_string title) { fprintf(stderr, "[ui] GPU backend unavailable, falling back to CPU software renderer\n"); return _cpu_app_run(width, height, title); } TP_END_TRY +#endif return 0; } +#if defined(__ANDROID__) +// ── Android stdout/stderr → logcat 重定向 ── +// Android NativeActivity 的 stdout/stderr 默认不输出到 logcat, +// 用 pipe + 后台线程将 stdout/stderr 转发到 __android_log_print, +// 使 PHP 的 echo/fprintf(stderr) 可通过 `adb logcat -s tphp` 查看。 +#include +#include +#include + +static int _android_log_fd = -1; +static pthread_t _log_thread; + +static void* _android_log_thread_fn(void* arg) { + (void)arg; + char buf[1024]; + ssize_t n; + while ((n = read(_android_log_fd, buf, sizeof(buf) - 1)) > 0) { + buf[n] = '\0'; + // 按行分割输出(logcat 每条独立) + char* line = buf; + char* next; + while ((next = memchr(line, '\n', (size_t)(buf + n - line))) != NULL) { + *next = '\0'; + if (*line) __android_log_print(ANDROID_LOG_INFO, "tphp", "%s", line); + line = next + 1; + } + if (*line) __android_log_print(ANDROID_LOG_INFO, "tphp", "%s", line); + } + return NULL; +} + +static void _android_redirect_stdout(void) { + int pipefd[2]; + if (pipe(pipefd) < 0) return; + _android_log_fd = pipefd[0]; + fflush(stdout); + fflush(stderr); + dup2(pipefd[1], STDOUT_FILENO); + dup2(pipefd[1], STDERR_FILENO); + close(pipefd[1]); + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stderr, NULL, _IONBF, 0); + pthread_create(&_log_thread, NULL, _android_log_thread_fn, NULL); + pthread_detach(_log_thread); +} + +// Android 入口:sokol 调用此函数获取配置,实际事件循环由 ANativeActivity 驱动 +// 签名必须与 sokol_app.h 的 extern 声明一致:sokol_main(int argc, char* argv[]) +// CodeGenerator 在 Android 模式下生成 tphp_android_main() 而非 main(), +// 此处首次调用时执行用户 main 来填充 _ui_android_desc(设置 callbacks 等)。 +// NativeActivity 重建(如屏幕旋转)时 sokol 会再次调用 sokol_main, +// 但 _ui_android_initialized 静态变量保证用户 main 只执行一次; +// 回调通过 tphp_fn_phpc_env_pin pin 住,重建后仍有效。 +SOKOL_API_IMPL sapp_desc sokol_main(int argc, char* argv[]) { + static int _ui_android_initialized = 0; + if (!_ui_android_initialized) { + _ui_android_initialized = 1; + // 在用户代码执行前重定向 stdout/stderr → logcat + _android_redirect_stdout(); + // Android NativeActivity 模式下 argc/argv 可能为 0/NULL,确保安全 + if (argc < 0) argc = 0; + if (argv == NULL && argc > 0) argc = 0; + tphp_android_main(argc, argv); + } + return _ui_android_desc; +} +#endif + static inline void ui_app_on_init(t_callback cb) { _ui_state.on_init_cb = cb; tphp_fn_phpc_env_pin(cb); @@ -665,20 +1219,151 @@ static inline t_int ui_event_touch_count(t_int ev_ptr) { } // ── SoftInput 回调存储(C 层管理,避免 PHP mixed 属性的 null 赋值问题)── -static t_callback _ui_softinput_cb = {NULL, NULL}; +// _ui_softinput_cb 已在前方声明(cleanup_cb 需要),此处不再重复定义 + +// show/hide:桌面端 no-op,Android 端通过 JNI 调用 InputMethodManager +#if defined(__ANDROID__) +// JNI 线程 attach 辅助:仅在当前线程未 attach 时才 attach,对应时才 detach +// 直接 AttachCurrentThread 在已 attached 线程上会增加引用计数, +// 后续 DetachCurrentThread 会导致 sokol 主线程 env 失效。 +typedef struct { JNIEnv* env; int need_detach; } _ui_jni_env_t; +static inline _ui_jni_env_t _ui_jni_attach(JavaVM* vm) { + _ui_jni_env_t r = { NULL, 0 }; + if ((*vm)->GetEnv(vm, (void**)&r.env, JNI_VERSION_1_6) == JNI_OK) + return r; // 已 attached + if ((*vm)->AttachCurrentThread(vm, &r.env, NULL) == JNI_OK) + r.need_detach = 1; + return r; +} +static inline void _ui_jni_detach(JavaVM* vm, _ui_jni_env_t* r) { + if (r->need_detach) (*vm)->DetachCurrentThread(vm); +} +// JNI 异常检查:清除异常并输出警告 +static inline int _ui_jni_check(JNIEnv* env, const char* tag) { + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + fprintf(stderr, "[ui] JNI exception in %s\n", tag); + return 1; + } + return 0; +} +static inline void _ui_android_show_softinput(void) { + ANativeActivity* activity = (ANativeActivity*)sapp_android_get_native_activity(); + if (!activity) { tp_throw("ui_softinput_show: NativeActivity not available"); return; } + _ui_jni_env_t je = _ui_jni_attach(activity->vm); + if (!je.env) { tp_throw("ui_softinput_show: AttachCurrentThread failed"); return; } + JNIEnv* env = je.env; + // activity.getWindow().getDecorView() + jclass cls_NativeActivity = (*env)->FindClass(env, "android/app/NativeActivity"); + if (!cls_NativeActivity || _ui_jni_check(env, "show:FindClass NativeActivity")) goto show_done; + jmethodID mid_getWindow = (*env)->GetMethodID(env, cls_NativeActivity, "getWindow", "()Landroid/view/Window;"); + if (!mid_getWindow || _ui_jni_check(env, "show:GetMethodID getWindow")) goto show_cleanup_na; + jobject window = (*env)->CallObjectMethod(env, activity->clazz, mid_getWindow); + if (_ui_jni_check(env, "show:getWindow") || !window) goto show_cleanup_na; + jclass cls_Window = (*env)->FindClass(env, "android/view/Window"); + if (!cls_Window || _ui_jni_check(env, "show:FindClass Window")) { (*env)->DeleteLocalRef(env, window); goto show_cleanup_na; } + jmethodID mid_getDecorView = (*env)->GetMethodID(env, cls_Window, "getDecorView", "()Landroid/view/View;"); + if (!mid_getDecorView || _ui_jni_check(env, "show:GetMethodID getDecorView")) { (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); goto show_cleanup_na; } + jobject decorView = (*env)->CallObjectMethod(env, window, mid_getDecorView); + if (_ui_jni_check(env, "show:getDecorView") || !decorView) { (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); goto show_cleanup_na; } + // getSystemService(Context.INPUT_METHOD_SERVICE) + jclass cls_Context = (*env)->FindClass(env, "android/content/Context"); + if (!cls_Context || _ui_jni_check(env, "show:FindClass Context")) { (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); (*env)->DeleteLocalRef(env, decorView); goto show_cleanup_na; } + jfieldID fid_IMM = (*env)->GetStaticFieldID(env, cls_Context, "INPUT_METHOD_SERVICE", "Ljava/lang/String;"); + if (!fid_IMM || _ui_jni_check(env, "show:GetStaticFieldID IMM")) { (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); (*env)->DeleteLocalRef(env, decorView); (*env)->DeleteLocalRef(env, cls_Context); goto show_cleanup_na; } + jstring str_imm = (jstring)(*env)->GetStaticObjectField(env, cls_Context, fid_IMM); + jmethodID mid_getSystemService = (*env)->GetMethodID(env, cls_Context, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;"); + if (!mid_getSystemService || _ui_jni_check(env, "show:GetMethodID getSystemService")) { (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); (*env)->DeleteLocalRef(env, decorView); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); goto show_cleanup_na; } + jobject imm = (*env)->CallObjectMethod(env, activity->clazz, mid_getSystemService, str_imm); + if (_ui_jni_check(env, "show:getSystemService") || !imm) { (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); (*env)->DeleteLocalRef(env, decorView); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); goto show_cleanup_na; } + // imm.showSoftInput(decorView, 0) + jclass cls_IMM = (*env)->GetObjectClass(env, imm); + jmethodID mid_show = (*env)->GetMethodID(env, cls_IMM, "showSoftInput", "(Landroid/view/View;I)Z"); + if (mid_show) { + (*env)->CallBooleanMethod(env, imm, mid_show, decorView, (jint)0); + _ui_jni_check(env, "show:showSoftInput"); + } + (*env)->DeleteLocalRef(env, imm); + (*env)->DeleteLocalRef(env, cls_IMM); + (*env)->DeleteLocalRef(env, str_imm); + (*env)->DeleteLocalRef(env, cls_Context); + (*env)->DeleteLocalRef(env, decorView); + (*env)->DeleteLocalRef(env, cls_Window); + (*env)->DeleteLocalRef(env, window); +show_cleanup_na: + (*env)->DeleteLocalRef(env, cls_NativeActivity); +show_done: + _ui_jni_detach(activity->vm, &je); +} +static inline void _ui_android_hide_softinput(void) { + ANativeActivity* activity = (ANativeActivity*)sapp_android_get_native_activity(); + if (!activity) { tp_throw("ui_softinput_hide: NativeActivity not available"); return; } + _ui_jni_env_t je = _ui_jni_attach(activity->vm); + if (!je.env) { tp_throw("ui_softinput_hide: AttachCurrentThread failed"); return; } + JNIEnv* env = je.env; + // getSystemService(Context.INPUT_METHOD_SERVICE) + jclass cls_Context = (*env)->FindClass(env, "android/content/Context"); + if (!cls_Context || _ui_jni_check(env, "hide:FindClass Context")) goto hide_done; + jfieldID fid_IMM = (*env)->GetStaticFieldID(env, cls_Context, "INPUT_METHOD_SERVICE", "Ljava/lang/String;"); + if (!fid_IMM || _ui_jni_check(env, "hide:GetStaticFieldID IMM")) { (*env)->DeleteLocalRef(env, cls_Context); goto hide_done; } + jstring str_imm = (jstring)(*env)->GetStaticObjectField(env, cls_Context, fid_IMM); + jmethodID mid_getSystemService = (*env)->GetMethodID(env, cls_Context, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;"); + if (!mid_getSystemService || _ui_jni_check(env, "hide:GetMethodID getSystemService")) { (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); goto hide_done; } + jobject imm = (*env)->CallObjectMethod(env, activity->clazz, mid_getSystemService, str_imm); + if (_ui_jni_check(env, "hide:getSystemService") || !imm) { (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); goto hide_done; } + // activity.getWindow().getDecorView().getWindowToken() + jclass cls_NativeActivity = (*env)->FindClass(env, "android/app/NativeActivity"); + if (!cls_NativeActivity || _ui_jni_check(env, "hide:FindClass NativeActivity")) { (*env)->DeleteLocalRef(env, imm); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); goto hide_done; } + jmethodID mid_getWindow = (*env)->GetMethodID(env, cls_NativeActivity, "getWindow", "()Landroid/view/Window;"); + if (!mid_getWindow || _ui_jni_check(env, "hide:GetMethodID getWindow")) { (*env)->DeleteLocalRef(env, imm); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); (*env)->DeleteLocalRef(env, cls_NativeActivity); goto hide_done; } + jobject window = (*env)->CallObjectMethod(env, activity->clazz, mid_getWindow); + if (_ui_jni_check(env, "hide:getWindow") || !window) { (*env)->DeleteLocalRef(env, imm); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); (*env)->DeleteLocalRef(env, cls_NativeActivity); goto hide_done; } + jclass cls_Window = (*env)->FindClass(env, "android/view/Window"); + if (!cls_Window || _ui_jni_check(env, "hide:FindClass Window")) { (*env)->DeleteLocalRef(env, imm); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); (*env)->DeleteLocalRef(env, cls_NativeActivity); (*env)->DeleteLocalRef(env, window); goto hide_done; } + jmethodID mid_getDecorView = (*env)->GetMethodID(env, cls_Window, "getDecorView", "()Landroid/view/View;"); + if (!mid_getDecorView || _ui_jni_check(env, "hide:GetMethodID getDecorView")) { (*env)->DeleteLocalRef(env, imm); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); (*env)->DeleteLocalRef(env, cls_NativeActivity); (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); goto hide_done; } + jobject decorView = (*env)->CallObjectMethod(env, window, mid_getDecorView); + if (_ui_jni_check(env, "hide:getDecorView") || !decorView) { (*env)->DeleteLocalRef(env, imm); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); (*env)->DeleteLocalRef(env, cls_NativeActivity); (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); goto hide_done; } + jclass cls_View = (*env)->FindClass(env, "android/view/View"); + if (!cls_View || _ui_jni_check(env, "hide:FindClass View")) { (*env)->DeleteLocalRef(env, imm); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); (*env)->DeleteLocalRef(env, cls_NativeActivity); (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); (*env)->DeleteLocalRef(env, decorView); goto hide_done; } + jmethodID mid_getWindowToken = (*env)->GetMethodID(env, cls_View, "getWindowToken", "()Landroid/os/IBinder;"); + if (!mid_getWindowToken || _ui_jni_check(env, "hide:GetMethodID getWindowToken")) { (*env)->DeleteLocalRef(env, imm); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); (*env)->DeleteLocalRef(env, cls_NativeActivity); (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); (*env)->DeleteLocalRef(env, decorView); (*env)->DeleteLocalRef(env, cls_View); goto hide_done; } + jobject windowToken = (*env)->CallObjectMethod(env, decorView, mid_getWindowToken); + if (_ui_jni_check(env, "hide:getWindowToken") || !windowToken) { (*env)->DeleteLocalRef(env, imm); (*env)->DeleteLocalRef(env, cls_Context); (*env)->DeleteLocalRef(env, str_imm); (*env)->DeleteLocalRef(env, cls_NativeActivity); (*env)->DeleteLocalRef(env, window); (*env)->DeleteLocalRef(env, cls_Window); (*env)->DeleteLocalRef(env, decorView); (*env)->DeleteLocalRef(env, cls_View); goto hide_done; } + // imm.hideSoftInputFromWindow(windowToken, 0) + jclass cls_IMM = (*env)->GetObjectClass(env, imm); + jmethodID mid_hide = (*env)->GetMethodID(env, cls_IMM, "hideSoftInputFromWindow", "(Landroid/os/IBinder;I)Z"); + if (mid_hide) { + (*env)->CallBooleanMethod(env, imm, mid_hide, windowToken, (jint)0); + _ui_jni_check(env, "hide:hideSoftInputFromWindow"); + } + (*env)->DeleteLocalRef(env, windowToken); + (*env)->DeleteLocalRef(env, cls_IMM); + (*env)->DeleteLocalRef(env, cls_View); + (*env)->DeleteLocalRef(env, decorView); + (*env)->DeleteLocalRef(env, cls_Window); + (*env)->DeleteLocalRef(env, window); + (*env)->DeleteLocalRef(env, cls_NativeActivity); + (*env)->DeleteLocalRef(env, imm); + (*env)->DeleteLocalRef(env, str_imm); + (*env)->DeleteLocalRef(env, cls_Context); +hide_done: + _ui_jni_detach(activity->vm, &je); +} +#endif -// show/hide:桌面端 no-op,Android 端 stub 抛异常(需 ext/jni 实现完整 JNI 桥接) static inline void ui_softinput_show(void) { #if defined(__ANDROID__) - tp_throw("Android soft input not yet implemented"); + _ui_android_show_softinput(); #else - // 桌面端:no-op(有物理键盘,无需软键盘) + // 桌面端:no-op #endif } static inline void ui_softinput_hide(void) { #if defined(__ANDROID__) - tp_throw("Android soft input not yet implemented"); + _ui_android_hide_softinput(); #else // 桌面端:no-op #endif diff --git a/ext/ui/src/ui.php b/ext/ui/src/ui.php index 73ac6c3..f2fd704 100644 --- a/ext/ui/src/ui.php +++ b/ext/ui/src/ui.php @@ -24,6 +24,7 @@ #flag windows -lgdi32 -luser32 -lopengl32 -lshell32 #flag linux -lX11 -lGL -lXi -lXcursor -ldl -lpthread #flag darwin -framework Cocoa -framework MetalKit -framework Metal -framework QuartzCore +#flag android -landroid -lEGL -lGLESv3 -llog #include __EXT__ . "ui/src/ui.h" diff --git a/ext/ui/src/ui_cpu.h b/ext/ui/src/ui_cpu.h index 89ef586..98a150d 100644 --- a/ext/ui/src/ui_cpu.h +++ b/ext/ui/src/ui_cpu.h @@ -427,6 +427,30 @@ static inline void _cpu_window_set_cursor(t_int cursor) { } } +#elif defined(__ANDROID__) +// ── Android CPU 后端(stub:实际渲染由 sokol GLES3 处理)── +static int _cpu_app_run(t_int width, t_int height, t_string title) { + // Android 上事件循环由 ANativeActivity 驱动,此函数不应被调用 + // 参数仅为保持签名与其他平台一致,避免调用点类型不匹配 + (void)width; (void)height; (void)title; + return 0; +} +static int _cpu_window_width(void) { + // 从 ANativeWindow 获取宽度(如果可用) + return 0; +} +static int _cpu_window_height(void) { + return 0; +} +static float _cpu_window_dpi_scale(void) { + return 1.0f; +} +static void _cpu_window_set_cursor(int cursor) { + // Android 无硬件鼠标光标 + (void)cursor; +} +static ui_draw_device_t _ui_cpu_device = {0}; + #elif defined(__linux__) // ── Linux X11 CPU 后端(后续实现)── // 当前 Linux 环境(桌面)通常有 GPU 或 llvmpipe 软件渲染, diff --git a/ext/ui/src/ui_softinput.php b/ext/ui/src/ui_softinput.php index 365a938..eb99c02 100644 --- a/ext/ui/src/ui_softinput.php +++ b/ext/ui/src/ui_softinput.php @@ -5,7 +5,7 @@ // 桌面端实现:show/hide 为 no-op(桌面端有物理键盘,无需软键盘), // onInput 注册的回调由 Char 事件触发。 // -// 移动端(未来支持):show/hide 调用平台原生 API 弹出/收起软键盘。 +// 移动端(Android):show/hide 通过 JNI 调用 InputMethodManager 弹出/收起软键盘。 // // 回调存储在 C 层(C->_ui_softinput_cb),避免 PHP mixed 属性的 null 赋值问题。 @@ -38,14 +38,14 @@ class SoftInput public static function show(): void { - // 桌面端:C 层 no-op;Android 端:C 层 stub 抛 Exception(需 ext/jni) + // 桌面端:C 层 no-op;Android 端:通过 JNI 调用 InputMethodManager C->ui_softinput_show(); self::$visible = true; } public static function hide(): void { - // 桌面端:C 层 no-op;Android 端:C 层 stub 抛 Exception(需 ext/jni) + // 桌面端:C 层 no-op;Android 端:通过 JNI 调用 InputMethodManager C->ui_softinput_hide(); self::$visible = false; } diff --git a/include/builtin_full.h b/include/builtin_full.h index 7798cc8..37e2e91 100644 --- a/include/builtin_full.h +++ b/include/builtin_full.h @@ -62,8 +62,31 @@ static void tphp_fn_var_dump_rec(t_var v, int depth) { const t_object* obj = (const t_object*)v.value._object; if (obj == NULL || obj->cls == NULL) { fputs("NULL", stdout); + } else if (strcmp(obj->cls->name, "stdClass") == 0) { + tphp_class_stdClass* s = (tphp_class_stdClass*)obj; + int cnt = tphp_fn_stdclass_count(s); + fprintf(stdout, "object(stdClass)#%d (%d) {\n", obj->id, cnt); + if (s->props != NULL) { + for (int i = 0; i < cnt; i++) { + t_var* key = &s->props->entries[i].key; + t_var* val = &s->props->entries[i].val; + tphp_fn_var_dump_indent(depth + 1); + if (key->type == TYPE_STRING) { + int klen = key->value._string.length; + fprintf(stdout, "[\"%.*s\"]=>\n", klen, + (STR_PTR_V(key->value._string) != NULL) ? STR_PTR_V(key->value._string) : ""); + } else { + fprintf(stdout, "[%lld]=>\n", (long long)key->value._int); + } + tphp_fn_var_dump_indent(depth + 1); + tphp_fn_var_dump_rec(*val, depth + 1); + fputc('\n', stdout); + } + } + tphp_fn_var_dump_indent(depth); + fputc('}', stdout); } else { - fprintf(stdout, "object(%s)", obj->cls->name ? obj->cls->name : "?"); + fprintf(stdout, "object(%s)#%d", obj->cls->name ? obj->cls->name : "?", obj->id); } break; } diff --git a/include/common.h b/include/common.h index 5360de2..c1d46cb 100644 --- a/include/common.h +++ b/include/common.h @@ -35,6 +35,7 @@ static inline void tphp_rt_free_all(void); #include "object/parallel.h" // Parallel::map/for 数据并行 API #include "object/channel.h" // Channel/Future/chan_select 异步与协程通信库 #include "rand.h" // CSPRNG (builtin.h 需要,内部用 tp_throw) +#include "object/stdclass.h" // stdClass 类(动态属性容器,需在 builtin.h 前,var_dump 引用其类型) #include "builtin.h" #include "tphp_math.h" // TinyPHP math 扩展 #include "conv.h" diff --git a/include/object/stdclass.h b/include/object/stdclass.h new file mode 100644 index 0000000..a4d00de --- /dev/null +++ b/include/object/stdclass.h @@ -0,0 +1,182 @@ +#ifndef TINYPHP_OBJECT_STDCLASS_H +#define TINYPHP_OBJECT_STDCLASS_H +#pragma once +// ============================================================ +// stdclass.h — PHP 原生 stdClass 类(动态属性容器) +// +// PHP 原生设计参考: +// - zend_object { gc, class_type, properties_table } +// - stdClass 是所有类的默认基类(无预定义属性/方法) +// - 动态属性存储在 properties HashTable(字符串键 → zval) +// +// AOT 适配: +// - 用 t_array 万能数组作为动态属性表(字符串键 → t_var) +// - 复用 array.h 的哈希索引实现 O(1) 属性查找 +// - static inline 函数,每个 TU 独立副本(与 exception.h 一致) +// +// 依赖顺序:必须在 array.h 和 runtime.h 之后 include +// (依赖 t_array API 和 tphp_rt_register) +// ============================================================ + +// ── stdClass struct (COS 对象头 + 动态属性表) ───────────── +typedef struct { + t_object _obj; // COS 对象头(cls + refcount + id) + t_array* props; // 动态属性表(字符串键,值为 t_var) +} tphp_class_stdClass; + +// ── 前向声明(static inline: 每个 TU 独立副本,避免链接时重复定义)── +static inline tphp_class_stdClass* new_stdClass(void); +static inline void tphp_class_stdClass___destruct(tphp_class_stdClass* self); +static inline t_var tphp_fn_stdclass_get(tphp_class_stdClass* self, t_string name); +static inline void tphp_fn_stdclass_set(tphp_class_stdClass* self, t_string name, t_var val); +static inline t_bool tphp_fn_stdclass_isset(tphp_class_stdClass* self, t_string name); +static inline void tphp_fn_stdclass_unset(tphp_class_stdClass* self, t_string name); +static inline tphp_class_stdClass* tphp_fn_stdclass_clone(tphp_class_stdClass* self); +static inline int tphp_fn_stdclass_count(tphp_class_stdClass* self); + +// ── 类描述符 ────────────────────────────────────────────── +static void* _vtable_tphp_class_stdClass[1] = { NULL }; +static const t_class _class_tphp_class_stdClass = { + .name = "stdClass", + .parent = NULL, + .instance_size = sizeof(tphp_class_stdClass), + .exception_offset = 0, + .dtor = (void*)tphp_class_stdClass___destruct, + .vtable = _vtable_tphp_class_stdClass, + .vtable_len = 0, +}; + +// ══════════════════════════════════════════════════════════ +// 构造函数: new stdClass() +// 用 tp_obj_alloc 分配,初始化 props 空表,注册到运行时 +// ══════════════════════════════════════════════════════════ +static inline tphp_class_stdClass* new_stdClass(void) { + tphp_class_stdClass* self = (tphp_class_stdClass*)tp_obj_alloc(&_class_tphp_class_stdClass); + if (self == NULL) return NULL; + self->props = tphp_fn_arr_create(8); + tphp_rt_register((void*)self, 0); + return self; +} + +// ══════════════════════════════════════════════════════════ +// 析构函数: __destruct +// 释放动态属性表(tphp_fn_arr_free 内部递归释放嵌套数组/对象) +// ══════════════════════════════════════════════════════════ +static inline void tphp_class_stdClass___destruct(tphp_class_stdClass* self) { + if (self == NULL) return; + if (self->props != NULL) { + tphp_fn_arr_free(self->props); + self->props = NULL; + } +} + +// ══════════════════════════════════════════════════════════ +// 属性读取: $obj->name +// 用 tphp_fn_arr_get_str 查找;不存在时返回 VAR_NULL() +// (不触发 warning,warning 由编译器层处理或后续添加) +// ══════════════════════════════════════════════════════════ +static inline t_var tphp_fn_stdclass_get(tphp_class_stdClass* self, t_string name) { + if (self == NULL || self->props == NULL) return VAR_NULL(); + t_var *entry = tphp_fn_arr_get_str(self->props, name); + if (entry == NULL) return VAR_NULL(); + return *entry; +} + +// ══════════════════════════════════════════════════════════ +// 属性赋值: $obj->name = $val +// tphp_fn_arr_set_str 返回 t_array*(可能 realloc),需回写 self->props +// ══════════════════════════════════════════════════════════ +static inline void tphp_fn_stdclass_set(tphp_class_stdClass* self, t_string name, t_var val) { + if (self == NULL) return; + self->props = tphp_fn_arr_set_str(self->props, name, val); +} + +// ══════════════════════════════════════════════════════════ +// 属性检查: isset($obj->name) +// 返回 t_bool,PHP 语义:键存在且值非 null 才返回 true +// (与 PHP isset() 一致:null 值属性视为未设置) +// ══════════════════════════════════════════════════════════ +static inline t_bool tphp_fn_stdclass_isset(tphp_class_stdClass* self, t_string name) { + if (self == NULL || self->props == NULL) return false; + t_var *entry = tphp_fn_arr_get_str(self->props, name); + return entry != NULL && entry->type != TYPE_NULL; +} + +// ══════════════════════════════════════════════════════════ +// 属性删除: unset($obj->name) +// 调用 tphp_fn_arr_unset_str(内部释放值的引用 + 维护哈希索引) +// ══════════════════════════════════════════════════════════ +static inline void tphp_fn_stdclass_unset(tphp_class_stdClass* self, t_string name) { + if (self == NULL || self->props == NULL) return; + tphp_fn_arr_unset_str(self->props, name); +} + +// ══════════════════════════════════════════════════════════ +// 克隆: clone $obj +// new_stdClass() 创建新对象,遍历 self->props 复制每个属性到新对象 +// (tphp_fn_arr_set_str 内部会 retain 引用计数的值) +// ══════════════════════════════════════════════════════════ +static inline tphp_class_stdClass* tphp_fn_stdclass_clone(tphp_class_stdClass* self) { + if (self == NULL) return NULL; + tphp_class_stdClass* clone = new_stdClass(); + if (clone == NULL) return NULL; + if (self->props != NULL) { + for (int i = 0; i < self->props->length; i++) { + if (self->props->entries[i].key.type == TYPE_STRING) { + clone->props = tphp_fn_arr_set_str(clone->props, + self->props->entries[i].key.value._string, + self->props->entries[i].val); + } + } + } + return clone; +} + +// ══════════════════════════════════════════════════════════ +// 属性计数: count(get_object_vars($obj)) +// 返回 tphp_fn_arr_count(self->props) +// ══════════════════════════════════════════════════════════ +static inline int tphp_fn_stdclass_count(tphp_class_stdClass* self) { + if (self == NULL || self->props == NULL) return 0; + return tphp_fn_arr_count(self->props); +} + +// ══════════════════════════════════════════════════════════ +// 从数组创建 stdClass: (object) $array +// 遍历数组所有键值对,复制为 stdClass 动态属性 +// 仅字符串键被复制(整数键在 PHP 中会被转为字符串 "0","1"...) +// ══════════════════════════════════════════════════════════ +static inline tphp_class_stdClass* tphp_fn_stdclass_from_array(t_array* arr) { + tphp_class_stdClass* obj = new_stdClass(); + if (obj == NULL || arr == NULL) return obj; + for (int i = 0; i < arr->length; i++) { + t_var* key = &arr->entries[i].key; + t_var* val = &arr->entries[i].val; + if (key->type == TYPE_STRING) { + obj->props = tphp_fn_arr_set_str(obj->props, key->value._string, *val); + } else if (key->type == TYPE_INT) { + // 整数键转字符串键(PHP 兼容行为) + t_string ks = tphp_rt_str_from_int(key->value._int); + obj->props = tphp_fn_arr_set_str(obj->props, ks, *val); + } + } + return obj; +} + +// ══════════════════════════════════════════════════════════ +// stdClass 转数组: (array) $stdClass / get_object_vars($obj) +// 创建新 t_array,复制 stdClass 所有动态属性 +// ══════════════════════════════════════════════════════════ +static inline t_array* tphp_fn_stdclass_to_array(tphp_class_stdClass* self) { + if (self == NULL || self->props == NULL) return tphp_fn_arr_create(0); + t_array* arr = tphp_fn_arr_create(self->props->length); + if (arr == NULL) return NULL; + for (int i = 0; i < self->props->length; i++) { + arr = tphp_fn_arr_set_str(arr, + self->props->entries[i].key.value._string, + self->props->entries[i].val); + } + return arr; +} + +#endif /* TINYPHP_OBJECT_STDCLASS_H */ diff --git a/include/std/core.h b/include/std/core.h index c7c7927..4c242e4 100644 --- a/include/std/core.h +++ b/include/std/core.h @@ -56,6 +56,29 @@ static void tphp_fn_var_dump_rec(t_var v, int depth) { const t_object* obj = (const t_object*)v.value._object; if (obj == NULL || obj->cls == NULL) { fputs("NULL", stdout); + } else if (strcmp(obj->cls->name, "stdClass") == 0) { + tphp_class_stdClass* s = (tphp_class_stdClass*)obj; + int cnt = tphp_fn_stdclass_count(s); + fprintf(stdout, "object(stdClass)#%u (%d) {\n", obj->id, cnt); + if (s->props != NULL) { + for (int i = 0; i < cnt; i++) { + t_var* key = &s->props->entries[i].key; + t_var* val = &s->props->entries[i].val; + tphp_fn_var_dump_indent(depth + 1); + if (key->type == TYPE_STRING) { + int klen = key->value._string.length; + fprintf(stdout, "[\"%.*s\"]=>\n", klen, + (STR_PTR_V(key->value._string) != NULL) ? STR_PTR_V(key->value._string) : ""); + } else { + fprintf(stdout, "[%lld]=>\n", (long long)key->value._int); + } + tphp_fn_var_dump_indent(depth + 1); + tphp_fn_var_dump_rec(*val, depth + 1); + fputc('\n', stdout); + } + } + tphp_fn_var_dump_indent(depth); + fputc('}', stdout); } else { const t_class* cls = obj->cls; const char* cname = cls->name ? cls->name : "?"; diff --git a/src/CodeGenerator.php b/src/CodeGenerator.php index 3657509..f62e709 100644 --- a/src/CodeGenerator.php +++ b/src/CodeGenerator.php @@ -105,6 +105,9 @@ class CodeGenerator implements ASTVisitor /** 是否 -shared 共享库模式(生成导出 trampoline + 库自动初始化) */ public bool $isShared = false; + /** 目标 OS(null = 宿主平台,'android' 时生成 tphp_android_main 而非 main) */ + public ?string $targetOS = null; + /** 字面量 → C 类型的映射 */ private static array $litTypeMap = [ IntLiteralExpr::class => 't_int', @@ -224,6 +227,7 @@ class CodeGenerator implements ASTVisitor 'parse_str' => 't_array*', 'preg_match' => 't_array*', 'preg_match_all' => 't_array*', 'preg_split' => 't_array*', 'preg_grep' => 't_array*', 'iconv_get_encoding' => 't_array*', + 'get_object_vars' => 't_array*', // ── zip 数组返回 ── 'zip_read' => 't_array*', 'zip_stat' => 't_array*', // ── zlib gz 数组返回 ── @@ -1298,7 +1302,7 @@ public function visitProgram(ProgramNode $node): string $this->sectionLine(self::SEC_CONSTS, '#define TPHP_CONST_PHP_OS_FAMILY STR_LIT("' . $phpOsFamily . '")'); $this->sectionLine(self::SEC_CONSTS, '#define TPHP_CONST_PHP_SAPI STR_LIT("cli")'); $this->sectionLine(self::SEC_CONSTS, '#define TPHP_CONST_PHP_VERSION STR_LIT("' . (defined('TPHP_VERSION') ? TPHP_VERSION : '0.0.0') . '")'); - $this->sectionLine(self::SEC_CONSTS, '#define TPHP_CONST_PHP_EXTRA_VERSION STR_LIT("-beta.3")'); + $this->sectionLine(self::SEC_CONSTS, '#define TPHP_CONST_PHP_EXTRA_VERSION STR_LIT("-beta.9")'); // 整数常量 $this->sectionLine(self::SEC_CONSTS, '#define TPHP_CONST_PHP_INT_MAX INT64_MAX'); $this->sectionLine(self::SEC_CONSTS, '#define TPHP_CONST_PHP_INT_MIN INT64_MIN'); @@ -1807,6 +1811,10 @@ private function resetState(): void $this->symbols->addClassProp('tphp_class_AnnotationEntry', 'name', 't_string'); $this->symbols->getClass('tphp_class_AnnotationEntry')->methods['__construct'] = new MethodInfo('void', ['t_array*', 't_string', 't_string']); $this->symbols->getClass('tphp_class_AnnotationEntry')->methods['__destruct'] = new MethodInfo('void'); + + // 内置 stdClass 类(动态属性容器,无 struct 字段,属性走 tphp_fn_stdclass_* 运行时函数) + $this->symbols->addClass('tphp_class_stdClass'); + $this->symbols->addClassName('stdClass', 'tphp_class_stdClass'); } // ── 多段输出方法 ───────────────────────────────────────── @@ -3600,7 +3608,14 @@ public function visitAssignStmt(AssignStmtNode $node): string $this->varTypes[$var] = 't_array*'; // 推导 cast 源类型作为数组元素类型 $srcType = $this->inferType($node->expr->expr); - $this->arrElementTypes[$var] = ($srcType === 'null' || $srcType === 'void*') ? 't_int' : $srcType; + // (array) $stdClass → tphp_fn_stdclass_to_array 生成万能数组,元素为 t_var + if (str_contains($srcType, 'tphp_class_stdClass')) { + $this->arrElementTypes[$var] = 't_var'; + // stdClass 属性名为字符串键,标记以供 foreach 键类型推导为 t_string + $this->arrValueTypes[$var] = ['__stdclass_props__' => 't_var']; + } else { + $this->arrElementTypes[$var] = ($srcType === 'null' || $srcType === 'void*') ? 't_int' : $srcType; + } if (!$isDeclared) { if ($this->scopeDepth > 0) { $this->funcScopeDecls[$var] = 't_array*'; @@ -4271,6 +4286,14 @@ private function inferType(ExprNode $expr): string && $this->inferredTypeToCType($expr->inferredType) === 't_var') { $skipInferred = true; } + // CastExpr (object) 转换:TypeChecker 推导 Type::$object → void*, + // 但 castToObject 生成 tphp_fn_stdclass_from_array 返回 tphp_class_stdClass* + // 场景:$obj = (object) $arr; — 需推导为 tphp_class_stdClass* 而非 void* + if ($expr instanceof CastExpr + && $expr->castType === 'object' + && $this->inferredTypeToCType($expr->inferredType) === 'void*') { + $skipInferred = true; + } } // t_var 变量持有数组的元素访问:C 代码(万能数组 getter)始终返回 t_var, // 即使 TypeChecker 推导为 t_int/t_string 等标量类型(PHP 语义正确但 C 实现不同) @@ -4435,6 +4458,10 @@ private function inferType(ExprNode $expr): string } // 尝试从 SymbolTable 查找(沿父类链查找继承属性) if ($objType !== '' && $this->symbols->hasClass($objType)) { + // stdClass 动态属性访问 → t_var(运行时 tphp_fn_stdclass_get 返回 t_var) + if ($objType === 'tphp_class_stdClass') { + return 't_var'; + } $propName = ltrim($expr->property, '$'); $pt = $this->symbols->getClassPropType($objType, $propName); if ($pt !== null) return $pt; @@ -4536,6 +4563,10 @@ private function inferType(ExprNode $expr): string return self::classRefName($expr->className) . '*'; } if ($expr instanceof CastExpr) { + // (object) 转换 → castToObject 生成 tphp_fn_stdclass_from_array,返回 stdClass + if ($expr->castType === 'object') { + return 'tphp_class_stdClass*'; + } // C.XXX cast → 值类型映射为 PHP 类型,指针类型保留 C 类型 if (str_starts_with($expr->castType, 'C.')) { $ct = substr($expr->castType, 2); @@ -4846,8 +4877,45 @@ private function inferCallReturnType(CallExpr $expr): string return 't_int'; } + /** 将任意类型值包装为 t_var(用于 stdClass 属性赋值) */ + private function wrapTVar(string $val, string $srcType): string + { + return match ($srcType) { + 't_int' => "VAR_INT({$val})", + 't_float' => "VAR_FLOAT({$val})", + 't_bool' => "VAR_BOOL({$val})", + 't_string' => "VAR_STRING({$val})", + 't_array*' => "VAR_ARRAY({$val})", + 'null' => "VAR_NULL()", + 't_var' => $val, + default => (str_contains($srcType, 'tphp_class_') || str_contains($srcType, 'tphp_enum_') + ? "VAR_OBJ((void*)({$val}))" + : "VAR_INT({$val})"), + }; + } + public function visitAssignPropStmt(AssignPropStmtNode $node): string { + // stdClass 动态属性赋值:$obj->prop = $val → tphp_fn_stdclass_set(obj, STR_LIT("prop"), VAR_XXX(val)) + $pa = $node->target; + $prop = ltrim($pa->property, '$'); + $assignObjCN = ''; + if ($pa->object instanceof VariableExpr) { + if ($pa->object->name === 'self' || $pa->object->name === '$this') { + $assignObjCN = $this->className; + } elseif (str_starts_with($pa->object->name, '$')) { + $objType = $this->varTypes[self::varName($pa->object->name)] ?? ''; + $assignObjCN = rtrim($objType, '*'); + } + } + if ($assignObjCN === 'tphp_class_stdClass') { + $obj = $pa->object->accept($this); + $val = $node->value->accept($this); + $srcType = $this->inferType($node->value); + // 按 srcType 包装为 t_var + $wrapped = $this->wrapTVar($val, $srcType); + return "tphp_fn_stdclass_set({$obj}, STR_LIT(\"{$prop}\"), {$wrapped});"; + } // Property Hook: set 拦截 — 不在 hook 体内时调用 setter if (!$this->inHookBody) { $pa = $node->target; @@ -5533,6 +5601,10 @@ private function getPropType(PropertyAccessExpr $pa): string } } if ($objType !== '' && $this->symbols->hasClass($objType)) { + // stdClass 动态属性 → t_var(运行时类型) + if ($objType === 'tphp_class_stdClass') { + return 't_var'; + } $propName = ltrim($pa->property, '$'); $pt = $this->symbols->getClassPropType($objType, $propName); if ($pt !== null) return $pt; @@ -8510,6 +8582,21 @@ public function visitCall(CallExpr $node): string return "(tphp_fn_arr_get_int({$arrCode}, (t_int)({$idxCode})) != NULL)"; } } + // isset($obj->prop) — stdClass 动态属性存在性检查 + if (!empty($node->args) && $node->args[0] instanceof PropertyAccessExpr) { + $pa = $node->args[0]; + $objCN = ''; + if ($pa->object instanceof VariableExpr) { + $vn = self::varName($pa->object->name); + $objType = $this->varTypes[$vn] ?? ''; + $objCN = rtrim($objType, '*'); + } + if ($objCN === 'tphp_class_stdClass') { + $obj = $pa->object->accept($this); + $prop = ltrim($pa->property, '$'); + return "(tphp_fn_stdclass_isset({$obj}, STR_LIT(\"{$prop}\")))"; + } + } $args = array_map(fn($a) => $a->accept($this), $node->args); $code = !empty($args) ? $args[0] : 'null'; $type = !empty($node->args) ? $this->inferType($node->args[0]) : 'null'; @@ -8537,6 +8624,21 @@ public function visitCall(CallExpr $node): string if ($node->callee === null && $node->name === 'unset') { $lines = []; foreach ($node->args as $arg) { + // unset($obj->prop) — stdClass 动态属性删除 + if ($arg instanceof PropertyAccessExpr) { + $objCN = ''; + if ($arg->object instanceof VariableExpr) { + $vn = self::varName($arg->object->name); + $objType = $this->varTypes[$vn] ?? ''; + $objCN = rtrim($objType, '*'); + } + if ($objCN === 'tphp_class_stdClass') { + $obj = $arg->object->accept($this); + $prop = ltrim($arg->property, '$'); + $lines[] = "tphp_fn_stdclass_unset({$obj}, STR_LIT(\"{$prop}\"))"; + continue; + } + } // 数组元素 unset: unset($arr[$key]) → 调用 C 运行时删除函数 if ($arg instanceof ArrayAccessExpr) { $arrCode = $arg->array->accept($this); @@ -8613,6 +8715,19 @@ public function visitCall(CallExpr $node): string }; return "tphp_fn_gettype({$w})"; } + if ($n === 'get_object_vars') { + $argType = $this->inferType($node->args[0]); + $argCode = $node->args[0]->accept($this); + // stdClass → 直接提取属性表 + if (str_contains($argType, 'tphp_class_stdClass')) { + return "tphp_fn_stdclass_to_array({$argCode})"; + } + // 其他对象类型:暂不支持(需遍历 public 属性,复杂度高) + throw new \RuntimeException( + sprintf("[%d:%d] get_object_vars() currently only supports stdClass, got %s", + $node->line, $node->column, $argType) + ); + } if ($n === 'number_format') { if (count($a) >= 2) return "tphp_fn_number_format2((t_float)({$a[0]}), {$a[1]})"; return "tphp_fn_number_format((t_float)({$a[0]}))"; @@ -9706,6 +9821,9 @@ public function visitCast(CastExpr $node): string if ($node->castType === 'array') { return $this->castToArray($node->expr); } + if ($node->castType === 'object') { + return $this->castToObject($node->expr); + } return '((' . self::mapType($node->castType) . ')(' . $node->expr->accept($this) . '))'; } @@ -9715,6 +9833,14 @@ public function visitNew(NewExpr $node): string $this->reorderNewArgs($node); $cn = self::classRefName($node->className); + if ($cn === 'tphp_class_stdClass') { + if (!empty($node->args)) { + throw new \RuntimeException( + sprintf("[%d:%d] stdClass does not accept constructor arguments", $node->line, $node->column) + ); + } + return 'new_stdClass()'; + } $args = array_map(fn($a) => $a->accept($this), $node->args); // 默认参数重载:构造函数有默认值参数且实参数量 < 总参数时,使用 new_cn_ 重载 $ctorInfo = $this->symbols->getClassMethod($cn, '__construct'); @@ -9823,6 +9949,14 @@ public function visitPropertyAccess(PropertyAccessExpr $node): string $objCN = rtrim($objType, '*'); } } + // stdClass 动态属性访问:$obj->prop → tphp_fn_stdclass_get(obj, c_str("prop")) + if ($objCN === 'tphp_class_stdClass' && !ctype_upper($prop[0] ?? '')) { + $access = "tphp_fn_stdclass_get({$obj}, STR_LIT(\"{$prop}\"))"; + if ($node->isNullsafe) { + return $this->wrapNullsafeAccess($obj, $access, 't_var'); + } + return $access; + } // Property Hook: get 拦截 — 不在 hook 体内时调用 getter if (!$this->inHookBody && $objCN !== '' && !ctype_upper($prop[0] ?? '')) { $hookInfo = $this->resolveHookInfo($objCN, $prop); @@ -11145,6 +11279,11 @@ public function visitForeachStmt(ForeachStmtNode $node): string return $this->emitGeneratorForeach($node); } + // stdClass foreach: 遍历内部 t_array 的动态属性 + if (str_contains($iterType, 'tphp_class_stdClass')) { + return $this->emitStdClassForeach($node); + } + // 泛型数组分支:t_arr_int*/t_arr_str*/t_arr_float*/t_arr_bool*/t_arr_ptr* // 直接访问 entries[i].val,无需 t_var 包装和类型检查 $genElemCType = self::genericArrayElemCType($iterType); @@ -11359,6 +11498,62 @@ public function visitForeachStmt(ForeachStmtNode $node): string return implode("\n", $lines); } + /** 生成 stdClass foreach 代码:遍历内部 t_array 的动态属性 */ + private function emitStdClassForeach(ForeachStmtNode $node): string + { + $objCode = $node->array->accept($this); + // 提取 props 指针(stdClass 结构体的动态属性表) + $propsCode = "((tphp_class_stdClass*)({$objCode}))->props"; + + $valVar = ltrim($node->valueVar, '$'); + $keyVar = $node->keyVar ? ltrim($node->keyVar, '$') : ''; + + $needValDecl = !isset($this->declaredVars[$valVar]); + $needKeyDecl = ($keyVar && !isset($this->declaredVars[$keyVar])); + + $this->declaredVars[$valVar] = true; + $this->varTypes[$valVar] = 't_var'; + if ($keyVar) { + $this->declaredVars[$keyVar] = true; + $this->varTypes[$keyVar] = 't_string'; + } + + $idx = '_fi_' . (++$this->tmpVarCounter); + $endLabel = '_lp_end_' . (++$this->tmpVarCounter); + $startLabel = '_lp_start_' . $this->tmpVarCounter; + $contLabel = '_lp_cont_' . $this->tmpVarCounter; + $this->loopEndLabelStack[] = $endLabel; + $this->loopStartLabelStack[] = $startLabel; + $this->loopContLabelStack[] = $contLabel; + + $lines = []; + if ($needKeyDecl) { + $lines[] = "t_string {$keyVar};"; + } + if ($needValDecl) { + $lines[] = "t_var {$valVar};"; + } + $lines[] = "{$startLabel}:;"; + $lines[] = "for (int {$idx} = 0; {$idx} < tphp_fn_arr_count({$propsCode}); {$idx}++) {"; + $lines[] = $this->ind("if ({$propsCode} == NULL) break;"); + $lines[] = $this->ind("const t_arr_entry* _se = &{$propsCode}->entries[{$idx}];"); + if ($keyVar) { + $lines[] = $this->ind("{$keyVar} = (_se->key.type == TYPE_STRING) ? _se->key.value._string : ((t_string){NULL, 0});"); + } + $lines[] = $this->ind("{$valVar} = _se->val;"); + $this->scopeDepth++; + foreach ($node->body as $s) $lines[] = $this->ind($s->accept($this)); + $this->scopeDepth--; + $lines[] = $this->ind("{$contLabel}:;"); + $lines[] = '}'; + $lines[] = "{$endLabel}:;"; + + array_pop($this->loopEndLabelStack); + array_pop($this->loopStartLabelStack); + array_pop($this->loopContLabelStack); + return implode("\n", $lines); + } + /** 生成器 foreach:while (valid) { key/current; body; next; } */ private function emitGeneratorForeach(ForeachStmtNode $node): string { @@ -12092,9 +12287,11 @@ private function wrapArrVal(ExprNode $expr, string $code): string // ============================================================ private function generateCEntry(): string { + $isAndroid = ($this->targetOS === 'android'); + $entryName = $isAndroid ? 'tphp_android_main' : 'main'; $lines = [ - "/* ── C entry: main() ─────────────────────────── */", - "int main(int argc, char* argv[]) {", + "/* ── C entry: {$entryName}() ─────────────────────────── */", + "int {$entryName}(int argc, char* argv[]) {", $this->ind("tphp_rt_init();"), ]; // PDO 驱动自动注册(类似 PHP MINIT,在用户代码之前) @@ -12109,8 +12306,16 @@ private function generateCEntry(): string $lines[] = $this->ind("{$this->className}* _main = new_{$this->className}((t_int)argc, _argv);"); $lines[] = $this->ind("if (_main == NULL) { tphp_fn_arr_free(_argv); return 1; }"); $lines[] = $this->ind("{$this->className}_main(_main);"); - $lines[] = $this->ind("tp_obj_release(_main);"); - $lines[] = $this->ind("tphp_fn_arr_free(_argv);"); + if ($isAndroid) { + // Android NativeActivity 模式:sokol_main 返回后事件循环才启动, + // 用户闭包可能通过 $this 捕获 Main 对象,不能在此释放。 + // 应用退出时进程直接终止,无需手动清理。 + // _argv 也保持存活(闭包可能通过 $argv 访问) + $lines[] = $this->ind("/* Android: keep _main/_argv alive — closures capture \$this */"); + } else { + $lines[] = $this->ind("tp_obj_release(_main);"); + $lines[] = $this->ind("tphp_fn_arr_free(_argv);"); + } $lines[] = $this->ind("return 0;"); $lines[] = "}"; return implode("\n", $lines); @@ -12333,9 +12538,37 @@ private function castToBool(ExprNode $expr): string private function castToArray(ExprNode $expr): string { if ($expr instanceof NullLiteralExpr) return 'tphp_fn_arr_create(0)'; + // (array) $stdClass → 提取属性表为关联数组 + $srcType = $this->inferType($expr); + if (str_contains($srcType, 'tphp_class_stdClass')) { + $code = $expr->accept($this); + return "tphp_fn_stdclass_to_array({$code})"; + } return 'tphp_fn_arr_from_val(' . $this->wrapVar($expr) . ')'; } + /** (object) $expr 转换:数组 → stdClass,其他 → stdClass(空或包装) */ + private function castToObject(ExprNode $expr): string + { + // (object) $array → stdClass from array + $srcType = $this->inferType($expr); + if ($srcType === 't_array*' || $srcType === 't_var') { + $code = $expr->accept($this); + if ($srcType === 't_var') { + // t_var 持有数组时,先提取 .value._array + $code = "(({$code}).value._array)"; + } + return "tphp_fn_stdclass_from_array({$code})"; + } + // (object) null → 空 stdClass + if ($expr instanceof NullLiteralExpr) { + return 'new_stdClass()'; + } + // 其他标量转 stdClass:PHP 中 (object)42 会创建 stdClass{"scalar": 42} + // 此场景较少见,简化处理为空 stdClass + return 'new_stdClass()'; + } + public function visitArrayAppend(ArrayAppendExpr $node): string { // $expr[] 在非赋值上下文无意义(PHP 中 $arr[] 单独使用会抛 Notice: Undefined variable) @@ -12803,6 +13036,7 @@ private function castToStr(ExprNode $expr, bool $strict = false): string $pt = $this->getPropType($expr); if ($pt === 't_string') return $code; if ($pt === 't_float') return "tphp_rt_str_from_float({$code})"; + if ($pt === 't_var') return "tphp_fn_strval({$code})"; } // CallExpr:查找返回类型(内置函数 + 方法调用 + 枚举方法) diff --git a/src/Lexer.php b/src/Lexer.php index 922f3ca..fd71887 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -242,7 +242,7 @@ private function scanToken(): void // 最多两个前缀:编译器 + 平台,顺序不限 // 注意:无前缀时 \s* 而非 \s+ — 允许 #flag -DNDEBUG 形式 // i 修饰符:平台/编译器前缀大小写不敏感(windows == Windows) - if (preg_match('/^#flag\s+(GCC|Clang|TCC|Windows|Linux|MacOS|Darwin)?\s*(GCC|Clang|TCC|Windows|Linux|MacOS|Darwin)?\s*(.+?)(?=\n|$)/i', $rest, $m)) { + if (preg_match('/^#flag\s+(GCC|Clang|TCC|Windows|Linux|MacOS|Darwin|Android)?\s*(GCC|Clang|TCC|Windows|Linux|MacOS|Darwin|Android)?\s*(.+?)(?=\n|$)/i', $rest, $m)) { $pf1 = $m[1] ?: ''; $pf2 = $m[2] ?: ''; $flags = trim($m[3]); @@ -250,12 +250,13 @@ private function scanToken(): void // 规范化为标准大小写(下游 $platformMap 用精确匹配键) $caseMap = [ 'windows' => 'Windows', 'linux' => 'Linux', 'macos' => 'MacOS', - 'darwin' => 'Darwin', 'gcc' => 'GCC', 'clang' => 'Clang', 'tcc' => 'TCC', + 'darwin' => 'Darwin', 'android' => 'Android', + 'gcc' => 'GCC', 'clang' => 'Clang', 'tcc' => 'TCC', ]; $allPf = array_filter([$pf1, $pf2]); foreach ($allPf as $p) { $p = $caseMap[strtolower($p)] ?? $p; - if (in_array($p, ['Windows','Linux','MacOS','Darwin'], true)) $platform = $p; + if (in_array($p, ['Windows','Linux','MacOS','Darwin','Android'], true)) $platform = $p; else $compiler = $p; } $this->addToken(TokenType::CC_FLAG, $compiler . ':' . $platform . ':' . $flags, [ diff --git a/src/Parser.php b/src/Parser.php index 070d6c0..64731fa 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -2333,6 +2333,10 @@ private function parsePrimary(): ExprNode if (in_array($nextType->type, $typeTokens, true)) { return $this->setPos($this->parseCastExpr(), $line, $col); } + // (object) cast — object 是普通 IDENTIFIER(非类型关键字),需额外识别 + if ($nextType->type === TokenType::IDENTIFIER && $nextType->lexeme === 'object') { + return $this->setPos($this->parseCastExpr(), $line, $col); + } // (C.XXX) cast — C 类型转换: (C.void*)$x, (C.char*)$buf, (C.int)$n if ($nextType->type === TokenType::IDENTIFIER && $nextType->lexeme === 'C' && $this->peek(2)->type === TokenType::DOT) { @@ -3288,6 +3292,7 @@ private function evalCtIdent(string $name): bool 'windows' => 'windows', 'win' => 'windows', 'linux' => 'linux', 'macos' => 'darwin', 'darwin' => 'darwin', 'mac' => 'darwin', + 'android' => 'android', ]; if (isset($osMap[$lower])) { return strtolower($this->targetOS) === $osMap[$lower]; diff --git a/src/TypeChecker.php b/src/TypeChecker.php index 34bd7a2..357d941 100644 --- a/src/TypeChecker.php +++ b/src/TypeChecker.php @@ -146,6 +146,7 @@ class TypeChecker implements ASTVisitor 'spl_autoload_unregister' => 'bool', 'spl_autoload_functions' => 'array', 'iterator_to_array' => 'array', 'iterator_count' => 'int', 'iterator_apply' => 'int', + 'get_object_vars' => 't_array*', // ── ext 扩展函数返回类型(与 CodeGenerator::$builtinRetTypes 保持同步)── // 避免扩展函数返回具体类型(如 Resource*)被推导为 mixed(t_var), // 导致变量声明类型不匹配(如 t_var zip = tphp_fn_zip_open() → 编译错误) @@ -560,6 +561,10 @@ private function registerBuiltinClasses(): void $this->symbols->addClassProp('tphp_class_AnnotationEntry', 'name', 't_string'); $this->symbols->addClassMethod('tphp_class_AnnotationEntry', '__construct', new MethodInfo('void', ['t_array*', 't_string', 't_string'])); $this->symbols->addClassMethod('tphp_class_AnnotationEntry', '__destruct', new MethodInfo('void')); + + // 内置 stdClass 类(动态属性容器) + $this->symbols->addClass('tphp_class_stdClass'); + $this->symbols->addClassName('stdClass', 'tphp_class_stdClass'); } /** 注册内置常量 */ @@ -2967,6 +2972,11 @@ private function checkPropertyAccess(PropertyAccessExpr $node): void $cName = $this->typeToCName($objType); if ($cName !== null) { + // stdClass 动态属性访问:返回 mixed(t_var),属性类型运行时确定 + if ($cName === 'tphp_class_stdClass') { + $node->inferredType = Type::$mixed; + return; + } // 枚举属性访问:name → string, value → backing type $enumCName = $this->symbols->resolveEnumCName($cName); if ($enumCName !== null) { diff --git a/test/object/stdclass_basic.php b/test/object/stdclass_basic.php new file mode 100644 index 0000000..32f7bfe --- /dev/null +++ b/test/object/stdclass_basic.php @@ -0,0 +1,22 @@ +foo = 42; + $obj->bar = "hello"; + echo $obj->foo . "\n"; + echo $obj->bar . "\n"; + var_dump($obj); + + // isset / unset + echo (isset($obj->foo) ? "yes" : "no") . "\n"; + unset($obj->foo); + echo (isset($obj->foo) ? "yes" : "no") . "\n"; + + // foreach + foreach ($obj as $key => $val) { + echo $key . "=" . $val . "\n"; + } + } +} diff --git a/test/object/stdclass_cast.php b/test/object/stdclass_cast.php new file mode 100644 index 0000000..1cc8e57 --- /dev/null +++ b/test/object/stdclass_cast.php @@ -0,0 +1,33 @@ + 'Alice', 'age' => 30, 'city' => 'Beijing']; + $obj = (object) $arr; + echo $obj->name . "\n"; + echo $obj->age . "\n"; + echo $obj->city . "\n"; + + // (array) stdClass + $obj2 = new stdClass(); + $obj2->x = 1; + $obj2->y = "hello"; + $obj2->z = true; + $back = (array) $obj2; + echo count($back) . "\n"; + foreach ($back as $k => $v) { + echo "$k=" . (string)$v . "\n"; + } + + // get_object_vars + $obj3 = new stdClass(); + $obj3->a = 10; + $obj3->b = 20; + $obj3->c = 30; + $vars = get_object_vars($obj3); + echo count($vars) . "\n"; + echo $vars['a'] . "\n"; + echo $vars['b'] . "\n"; + echo $vars['c'] . "\n"; + } +} diff --git a/test/object/stdclass_edge.php b/test/object/stdclass_edge.php new file mode 100644 index 0000000..bd94164 --- /dev/null +++ b/test/object/stdclass_edge.php @@ -0,0 +1,77 @@ +undefined; + echo " isset(undefined)=" . (isset($obj->undefined) ? "true" : "false") . "\n"; + echo " value=[" . (string)$val . "]\n"; + + // ── 空 stdClass foreach ── + echo "[3] foreach empty stdClass:\n"; + $count = 0; + foreach ($empty as $k => $v) { + $count = $count + 1; + } + echo " iterations=" . (string)$count . "\n"; + + // ── (object) 空数组 → 空 stdClass ── + echo "[4] (object) empty array:\n"; + $emptyArr = []; + $objFromEmpty = (object) $emptyArr; + $vars = get_object_vars($objFromEmpty); + echo " count=" . (string)count($vars) . "\n"; + var_dump($objFromEmpty); + + // ── (array) 空 stdClass → 空数组 ── + echo "[5] (array) empty stdClass:\n"; + $arrFromEmpty = (array) $empty; + echo " count=" . (string)count($arrFromEmpty) . "\n"; + + // ── 多次 set/unset 循环 ── + echo "[6] repeated set/unset cycle:\n"; + $cycler = new stdClass(); + for ($i = 0; $i < 3; $i = $i + 1) { + $cycler->prop = $i; + echo " set prop=" . (string)$cycler->prop . " isset=" . (isset($cycler->prop) ? "true" : "false") . "\n"; + unset($cycler->prop); + echo " unset prop isset=" . (isset($cycler->prop) ? "true" : "false") . "\n"; + } + $cycler->prop = 99; + echo " final prop=" . (string)$cycler->prop . "\n"; + + // ── 属性名为长字符串(使用字面量属性名,AOT 不支持 $obj->$var 动态属性名)── + echo "[7] long property name:\n"; + $longName = new stdClass(); + $longName->this_is_a_very_long_property_name_used_for_testing_edge_cases_in_stdclass_implementation = "long_value"; + echo " longKey isset=" . (isset($longName->this_is_a_very_long_property_name_used_for_testing_edge_cases_in_stdclass_implementation) ? "true" : "false") . "\n"; + echo " longKey value=" . (string)$longName->this_is_a_very_long_property_name_used_for_testing_edge_cases_in_stdclass_implementation . "\n"; + $longVars = get_object_vars($longName); + echo " longVars count=" . (string)count($longVars) . "\n"; + unset($longName->this_is_a_very_long_property_name_used_for_testing_edge_cases_in_stdclass_implementation); + echo " after unset isset=" . (isset($longName->this_is_a_very_long_property_name_used_for_testing_edge_cases_in_stdclass_implementation) ? "true" : "false") . "\n"; + + // ── 属性值为 null(PHP 语义:isset 对 null 值返回 false)── + echo "[8] null property value:\n"; + $nullObj = new stdClass(); + $nullObj->nullVal = null; + echo " isset(nullVal)=" . (isset($nullObj->nullVal) ? "true" : "false") . "\n"; + echo " value=[" . (string)$nullObj->nullVal . "]\n"; + var_dump($nullObj); + + // ── var_dump 最终状态 ── + echo "[9] final var_dump of cycler:\n"; + var_dump($cycler); + + echo "=== edge case tests done ===\n"; + } +} diff --git a/test/object/stdclass_foreach.php b/test/object/stdclass_foreach.php new file mode 100644 index 0000000..f877b38 --- /dev/null +++ b/test/object/stdclass_foreach.php @@ -0,0 +1,70 @@ + value 遍历所有属性 ── + echo "[1] foreach key => value:\n"; + $obj = new stdClass(); + $obj->a = 1; + $obj->b = 2; + $obj->c = 3; + foreach ($obj as $key => $val) { + echo " " . (string)$key . "=" . (string)$val . "\n"; + } + + // ── foreach 仅 value(无 key)── + echo "[2] foreach value only:\n"; + $obj2 = new stdClass(); + $obj2->x = 10; + $obj2->y = 20; + foreach ($obj2 as $val) { + echo " " . (string)$val . "\n"; + } + + // ── 空 stdClass foreach(不执行循环体)── + echo "[3] foreach empty stdClass:\n"; + $empty = new stdClass(); + $count = 0; + foreach ($empty as $k => $v) { + $count = $count + 1; + } + echo " iterations=" . (string)$count . "\n"; + + // ── 混合类型属性遍历(int/string/bool/null)── + echo "[4] foreach mixed types:\n"; + $mixed = new stdClass(); + $mixed->intVal = 42; + $mixed->strVal = "hello"; + $mixed->boolTrue = true; + $mixed->boolFalse = false; + $mixed->nullVal = null; + foreach ($mixed as $key => $val) { + echo " " . (string)$key . "=[" . (string)$val . "]\n"; + } + + // ── 遍历后验证属性数量 ── + echo "[5] verify property count after foreach:\n"; + $vars = get_object_vars($obj); + echo " count(obj)=" . (string)count($vars) . "\n"; + $vars2 = get_object_vars($mixed); + echo " count(mixed)=" . (string)count($vars2) . "\n"; + + // ── 嵌套 stdClass(属性值为另一个 stdClass)── + echo "[6] foreach nested stdClass:\n"; + $outer = new stdClass(); + $inner = new stdClass(); + $inner->x = 100; + $inner->y = "nested"; + $outer->inner = $inner; + $outer->top = 1; + foreach ($outer as $key => $val) { + echo " key=" . (string)$key . "\n"; + } + echo " var_dump nested:\n"; + var_dump($outer); + + echo "=== foreach tests done ===\n"; + } +} diff --git a/test/object/stdclass_functions.php b/test/object/stdclass_functions.php new file mode 100644 index 0000000..21ad17e --- /dev/null +++ b/test/object/stdclass_functions.php @@ -0,0 +1,61 @@ +name = "Alice"; + $obj->age = 30; + $obj->active = true; + $vars = get_object_vars($obj); + echo " count=" . (string)count($vars) . "\n"; + echo " name=" . (string)$vars['name'] . "\n"; + echo " age=" . (string)$vars['age'] . "\n"; + echo " active=" . (string)$vars['active'] . "\n"; + + // ── get_object_vars 在空 stdClass 上返回空数组 ── + echo "[2] get_object_vars on empty stdClass:\n"; + $empty = new stdClass(); + $emptyVars = get_object_vars($empty); + echo " count=" . (string)count($emptyVars) . "\n"; + + // ── (object) 数组后 get_object_vars 往返 ── + echo "[3] array -> object -> get_object_vars roundtrip:\n"; + $arr = ['x' => 1, 'y' => 2, 'z' => 3]; + $obj2 = (object) $arr; + $back = get_object_vars($obj2); + echo " count=" . (string)count($back) . "\n"; + echo " x=" . (string)$back['x'] . "\n"; + echo " y=" . (string)$back['y'] . "\n"; + echo " z=" . (string)$back['z'] . "\n"; + + // ── count(get_object_vars($obj)) 验证属性数量 ── + echo "[4] count(get_object_vars()) for various objects:\n"; + $obj3 = new stdClass(); + $obj3->a = 10; + $obj3->b = 20; + $obj3->c = 30; + echo " obj3 count=" . (string)count(get_object_vars($obj3)) . "\n"; + echo " empty count=" . (string)count(get_object_vars($empty)) . "\n"; + + // ── (object) -> (array) 后用 count 验证 ── + echo "[5] (array) stdClass then count:\n"; + $obj4 = new stdClass(); + $obj4->p = 1; + $obj4->q = 2; + $arrFromObj = (array) $obj4; + echo " count=" . (string)count($arrFromObj) . "\n"; + echo " p=" . (string)$arrFromObj['p'] . "\n"; + echo " q=" . (string)$arrFromObj['q'] . "\n"; + + // ── var_dump 输出验证 ── + echo "[6] var_dump validation:\n"; + var_dump($obj); + var_dump($empty); + + echo "=== built-in function tests done ===\n"; + } +} diff --git a/test/object/stdclass_isset_unset.php b/test/object/stdclass_isset_unset.php new file mode 100644 index 0000000..93af7d6 --- /dev/null +++ b/test/object/stdclass_isset_unset.php @@ -0,0 +1,49 @@ +intVal = 42; + $obj->strVal = "hello"; + $obj->boolVal = true; + echo "[setup] intVal=42, strVal=hello, boolVal=true\n"; + + // ── isset 已存在属性 → true ── + echo "[isset intVal] " . (isset($obj->intVal) ? "true" : "false") . "\n"; + echo "[isset strVal] " . (isset($obj->strVal) ? "true" : "false") . "\n"; + echo "[isset boolVal] " . (isset($obj->boolVal) ? "true" : "false") . "\n"; + + // ── isset 不存在属性 → false ── + echo "[isset missing] " . (isset($obj->missing) ? "true" : "false") . "\n"; + + // ── unset 已存在属性后再 isset → false ── + unset($obj->intVal); + echo "[after unset intVal] isset=" . (isset($obj->intVal) ? "true" : "false") . "\n"; + + // ── unset 不存在属性(不应报错)── + unset($obj->notExist); + echo "[unset notExist] ok (no error)\n"; + + // ── unset 后重新设置属性 ── + $obj->intVal = 99; + echo "[reset intVal=99] isset=" . (isset($obj->intVal) ? "true" : "false") . "\n"; + echo "[reset intVal value] " . (string)$obj->intVal . "\n"; + + // ── unset string 属性 ── + unset($obj->strVal); + echo "[after unset strVal] isset=" . (isset($obj->strVal) ? "true" : "false") . "\n"; + + // ── unset bool 属性 ── + unset($obj->boolVal); + echo "[after unset boolVal] isset=" . (isset($obj->boolVal) ? "true" : "false") . "\n"; + + // ── var_dump 验证最终状态 ── + echo "[final var_dump]\n"; + var_dump($obj); + + echo "=== isset/unset tests done ===\n"; + } +} diff --git a/test_ui_skeleton.php b/test_ui_skeleton.php deleted file mode 100644 index 6836e82..0000000 --- a/test_ui_skeleton.php +++ /dev/null @@ -1,10 +0,0 @@ - 'x86_64', 'amd64' => 'x86_64', 'x64' => 'x86_64', - 'aarch64' => 'aarch64', 'arm64' => 'aarch64', 'arm' => 'arm']; + 'aarch64' => 'aarch64', 'arm64' => 'aarch64', + 'armv7a' => 'armv7a', 'armeabi-v7a' => 'armv7a', + 'i686' => 'i686', 'x86' => 'i686', 'arm' => 'arm']; // Manual parse -cc xxx, -os xxx, -arch xxx and -o xxx (PHP getopt not fully compatible) $posArgs = []; +$archExplicit = false; // 用户是否显式指定 -arch(Android 多 ABI 模式判断用) +$skipAndroidApk = false; // 内部标志:多 ABI 子进程跳过 Gradle 打包 for ($i = 1, $n = count($argv); $i < $n; $i++) { if ($argv[$i] === '-cc' && isset($argv[$i + 1])) { $cc = $argv[++$i]; } elseif ($argv[$i] === '-arch' && isset($argv[$i + 1])) { $targetArch = $archMap[strtolower($argv[++$i])] ?? null; - if ($targetArch === null) die("Error: unknown arch '{$argv[$i-1]}'. Use: x86_64, aarch64\n"); + if ($targetArch === null) die("Error: unknown arch '{$argv[$i]}'. Use: x86_64, aarch64, armv7a, i686\n"); + $archExplicit = true; } elseif ($argv[$i] === '-os' && isset($argv[$i + 1])) { $targetOS = strtolower($argv[++$i]); // Normalize: macos → darwin if ($targetOS === 'macos' || $targetOS === 'mac') $targetOS = 'darwin'; + // Validate: reject unknown OS early to avoid confusing cross-compile errors + if (!in_array($targetOS, ['windows', 'linux', 'darwin', 'android'], true)) { + die("Error: unknown target OS '{$targetOS}'. Use: windows, linux, macos, android\n"); + } } elseif ($argv[$i] === '-o' && isset($argv[$i + 1])) { $outExe = $argv[++$i]; // 覆盖 getopt 解析 } elseif ($argv[$i] === '-shared') { $isShared = true; + } elseif ($argv[$i] === '--no-android-apk') { + // 内部标志:多 ABI 子进程编译 .so 时跳过 Gradle APK 构建(主进程统一打包) + $skipAndroidApk = true; } elseif (!str_starts_with($argv[$i], '-')) { $posArgs[] = $argv[$i]; } @@ -73,14 +85,18 @@ if (isset($options['os'])) { $targetOS = strtolower($options['os']); if ($targetOS === 'macos' || $targetOS === 'mac') $targetOS = 'darwin'; + if (!in_array($targetOS, ['windows', 'linux', 'darwin', 'android'], true)) { + die("Error: unknown target OS '{$targetOS}'. Use: windows, linux, macos, android\n"); + } } if (isset($options['arch']) && $targetArch === null) { $targetArch = $archMap[strtolower($options['arch'])] ?? null; - if ($targetArch === null) die("Error: unknown arch '{$options['arch']}'. Use: x86_64, aarch64\n"); + if ($targetArch === null) die("Error: unknown arch '{$options['arch']}'. Use: x86_64, aarch64, armv7a, i686\n"); + $archExplicit = true; } -// Default arch per target OS: Windows/Linux → x86_64, macOS → aarch64 +// Default arch per target OS: Windows/Linux → x86_64, macOS/Android → aarch64 if ($targetOS !== null && $targetArch === null) { - $targetArch = ($targetOS === 'darwin') ? 'aarch64' : 'x86_64'; + $targetArch = ($targetOS === 'darwin' || $targetOS === 'android') ? 'aarch64' : 'x86_64'; } if (isset($options['f'])) { @@ -191,7 +207,8 @@ . (PHP_OS_FAMILY === 'Windows' ? DIRECTORY_SEPARATOR . 'win32' . DIRECTORY_SEPARATOR . 'tcc.exe' : DIRECTORY_SEPARATOR . 'tcc'); - if (!file_exists($ccExe)) die("Error: built-in TCC not found: {$ccExe}\nBuild TCC first or use -cc to specify another compiler\n"); + // Android 目标使用 NDK clang(在后续 Android NDK 探测逻辑中设置),跳过 TCC 检查 + if ($targetOS !== 'android' && !file_exists($ccExe)) die("Error: built-in TCC not found: {$ccExe}\nBuild TCC first or use -cc to specify another compiler\n"); } if (!is_dir($includeDir)) die("Error: include directory not found: {$includeDir}\n"); @@ -203,12 +220,19 @@ $ccLower = strtolower($cc); if (str_contains($ccLower, 'gcc')) $ccClass = 'GCC'; elseif (str_contains($ccLower, 'clang')) $ccClass = 'Clang'; +} elseif ($targetOS === 'android') { + // Android 目标使用 NDK clang(Phase 2 才设置 $cc),条件编译求值阶段就需要知道是 Clang + $ccClass = 'Clang'; } elseif (PHP_OS_FAMILY === 'Darwin') { $ccClass = 'Clang'; } // 目标 OS/Arch(条件编译求值用):未指定时回退到宿主环境 $ctTargetOS = $targetOS ?? strtolower(PHP_OS_FAMILY); $ctTargetArch = $targetArch ?? strtolower(php_uname('m')); +// #flag/#include 平台过滤用:交叉编译时用目标 OS(大写形式,与 PHP_OS_FAMILY 一致) +$_effectiveOS = $targetOS !== null + ? match($targetOS) { 'windows' => 'Windows', 'linux' => 'Linux', 'darwin' => 'Darwin', 'android' => 'Android', default => PHP_OS_FAMILY } + : PHP_OS_FAMILY; // --- Phase 1: Transpile all PHP → C --- $allFilesStr = implode(', ', array_map(fn($f) => basename($f), $files)); @@ -473,9 +497,9 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { if (is_array($inc) && !empty($inc['ctx'])) { $ctx = $inc['ctx']; // Case-insensitive platform matching (accept windows/linux/macos/darwin lowercase) - $platformMap = ['windows' => 'Windows', 'linux' => 'Linux', 'darwin' => 'Darwin', 'macos' => 'Darwin']; + $platformMap = ['windows' => 'Windows', 'linux' => 'Linux', 'darwin' => 'Darwin', 'macos' => 'Darwin', 'android' => 'Android']; $ctxLower = strtolower($ctx); - $currentOS = PHP_OS_FAMILY; + $currentOS = $_effectiveOS; // OS filter if (isset($platformMap[$ctxLower]) && $platformMap[$ctxLower] !== $currentOS) return false; // Compiler filter (TCC/GCC/Clang) @@ -515,8 +539,8 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { // Extract -I paths from #flag directives (for #include search + security check) // __DIR__/__EXT__/__INC__/__CMD__ already expanded in prescan/parsing phase $flagIncludeDirs = []; - $_platformMap = ['Windows' => 'Windows', 'Linux' => 'Linux', 'Darwin' => 'Darwin', 'MacOS' => 'Darwin']; - $_currentOS = PHP_OS_FAMILY; + $_platformMap = ['Windows' => 'Windows', 'Linux' => 'Linux', 'Darwin' => 'Darwin', 'MacOS' => 'Darwin', 'Android' => 'Android']; + $_currentOS = $_effectiveOS; $_ccClass = 'TCC'; if ($cc !== null) { $_ccLower = strtolower($cc); @@ -667,8 +691,8 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { // Process #flag directives (filter by platform + compiler) if (!empty($allFlags)) { - $platformMap = ['Windows' => 'Windows', 'Linux' => 'Linux', 'Darwin' => 'Darwin', 'MacOS' => 'Darwin']; - $currentOS = PHP_OS_FAMILY; + $platformMap = ['Windows' => 'Windows', 'Linux' => 'Linux', 'Darwin' => 'Darwin', 'MacOS' => 'Darwin', 'Android' => 'Android']; + $currentOS = $_effectiveOS; // $ccClass 已在编译器选择阶段计算(条件编译共用) // Allowed #flag prefixes (whitelist — blocks arbitrary flag injection) $allowedFlagPrefixes = [ @@ -841,6 +865,7 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { } else { $gen = new CodeGenerator(); $gen->isShared = $isShared; + $gen->targetOS = $targetOS; $cFile = $gen->generate($merged, $entryFile, $outDir); } @@ -870,10 +895,80 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { 'windows' => '-D_WIN32 -DWIN32', 'linux' => '-D__linux__ -D__linux', 'darwin' => '-D__APPLE__ -D__MACH__', + 'android' => '-D__ANDROID__', ]; if (isset($platformDefines[$targetOS])) { $extraFlags .= ' ' . $platformDefines[$targetOS]; } + // Android NDK 探测(在通用跨编译器探测之前,Android 使用专用 NDK 工具链) + if ($targetOS === 'android' && $cc === null) { + $ndkPath = getenv('ANDROID_NDK') ?: getenv('ANDROID_NDK_HOME') ?: getenv('ANDROID_NDK_ROOT'); + if (!$ndkPath || !is_dir($ndkPath)) { + // 尝试从 ANDROID_HOME 推断 + $androidHome = getenv('ANDROID_HOME'); + if ($androidHome && is_dir($androidHome . '/ndk')) { + $ndkDirs = glob($androidHome . '/ndk/*', GLOB_ONLYDIR); + if (!empty($ndkDirs)) { + sort($ndkDirs); + $ndkPath = end($ndkDirs); + } + } + } + if (!$ndkPath || !is_dir($ndkPath)) { + $ndkExample = match(PHP_OS_FAMILY) { + 'Windows' => " Example: setx ANDROID_NDK \"C:\\Android\\ndk\\25.2.9519653\"\n", + default => " Example: export ANDROID_NDK=/opt/android-ndk\n", + }; + die("[!] Android NDK not found.\n" + . " Android NDK is required for cross-compiling to Android.\n" + . " Install via Android Studio SDK Manager (SDK Tools → NDK Side by side)\n" + . " or download from: https://developer.android.com/ndk/downloads\n" + . " Then set ANDROID_NDK (or ANDROID_NDK_HOME / ANDROID_NDK_ROOT) env var.\n" + . $ndkExample); + } + $ndkPath = str_replace('\\', '/', $ndkPath); + // 主机平台标识 + $hostTag = match(PHP_OS_FAMILY) { + 'Windows' => 'windows-x86_64', + 'Linux' => 'linux-x86_64', + 'Darwin' => 'darwin-x86_64', + default => 'windows-x86_64', + }; + // API level + $apiLevel = getenv('TPHP_ANDROID_API') ?: '24'; + // NDK clang 路径 + $ndkTriple = match($targetArch) { + 'aarch64' => 'aarch64-linux-android', + 'x86_64' => 'x86_64-linux-android', + 'armv7a' => 'armv7a-linux-androideabi', + 'i686' => 'i686-linux-android', + default => 'aarch64-linux-android', + }; + $ndkBinDir = "{$ndkPath}/toolchains/llvm/prebuilt/{$hostTag}/bin"; + $ndkClang = "{$ndkBinDir}/{$ndkTriple}{$apiLevel}-clang"; + // Windows 下用 .cmd 后缀 + if (PHP_OS_FAMILY === 'Windows') { + $ndkClangCmd = $ndkClang . '.cmd'; + if (!file_exists($ndkClangCmd)) { + // 某些 NDK 版本用 .bat + $ndkClangCmd = $ndkClang . '.bat'; + } + $ndkClang = file_exists($ndkClangCmd) ? $ndkClangCmd : $ndkClang; + } + if (!file_exists($ndkClang)) { + die("[!] NDK clang not found: {$ndkClang}\n" + . " Check ANDROID_NDK path and TPHP_ANDROID_API level.\n"); + } + $cc = $ndkClang; + $ccExe = $ndkClang; + $ccClass = 'Clang'; // NDK clang + // sysroot + $sysroot = "{$ndkPath}/toolchains/llvm/prebuilt/{$hostTag}/sysroot"; + $extraFlags = '--sysroot=' . escapeshellarg($sysroot) . ' ' . $extraFlags; + echo "[*] Using NDK: {$ndkPath} (API {$apiLevel})\n"; + echo "[*] NDK clang: {$ndkClang}\n"; + // Android 强制 -shared,产物为 .so(在后续编译命令中处理) + } // Cross-compiler auto-detection // Priority: 1. clang -target (native cross-compile) 2. GCC triplet if ($cc === null) { @@ -881,6 +976,13 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { 'windows' . $targetArch => "{$targetArch}-windows-gnu", 'linux' . $targetArch => "{$targetArch}-linux-gnu", 'darwin' . $targetArch => "{$targetArch}-apple-darwin", + 'android' . $targetArch => match($targetArch) { + 'aarch64' => 'aarch64-linux-android', + 'x86_64' => 'x86_64-linux-android', + 'armv7a' => 'armv7a-linux-androideabi', + 'i686' => 'i686-linux-android', + default => 'aarch64-linux-android', + }, ]; $targetTriple = $triplets[$targetOS . $targetArch] ?? ''; $found = null; @@ -893,6 +995,25 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { break; } } + // clang -target 跨编译需要目标平台的 sysroot(glibc 头文件 + crt objects)。 + // Windows 上的 clang 默认只有 MSVC/MinGW 头文件,没有 Linux glibc。 + // 用户可通过 TPHP_SYSROOT 环境变量指定 sysroot 路径(如 WSL rootfs)。 + if ($found !== null && $targetOS !== $currentOS) { + $sysroot = getenv('TPHP_SYSROOT'); + if ($sysroot && is_dir($sysroot)) { + $sysroot = str_replace('\\', '/', $sysroot); + $extraFlags = '--sysroot=' . escapeshellarg($sysroot) . ' ' . $extraFlags; + echo "[*] Using sysroot: {$sysroot}\n"; + } elseif ($targetOS === 'linux' && PHP_OS_FAMILY === 'Windows') { + die("[!] Cross-compile to Linux requires a Linux sysroot (glibc headers + crt).\n" + . " clang -target cannot find glibc headers without --sysroot.\n\n" + . " Option 1: Install WSL and set TPHP_SYSROOT\n" + . " set TPHP_SYSROOT=\\\\wsl$\\Ubuntu\n" + . " Option 2: Install a GCC cross-compiler and specify it\n" + . " -cc x86_64-linux-gnu-gcc -os linux\n" + . " Option 3: Compile natively on the target platform.\n"); + } + } // 2nd: try GCC cross-compiler triplets if ($found === null) { $gccTriplets = [ @@ -953,7 +1074,33 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { } } // Platform-specific output extension - if ($isShared) { + if ($targetOS === 'android') { + // Android 产物强制为 libtphp.so(共享库,需 -shared) + // NativeActivity 加载的 .so 必须以 lib 前缀开头 + // 所有 Android 产物统一放到 cwd/build/android/ 下,避免污染 ext/ui/android 源码模板: + // - libtphp.so → cwd/build/android/jniLibs//libtphp.so + // - app-debug.apk → cwd/build/android/app-debug.apk(gradle 打包时通过 -PtphpApkOut 重定向) + $abiMap = [ + 'aarch64' => 'arm64-v8a', + 'x86_64' => 'x86_64', + 'armv7a' => 'armeabi-v7a', + 'i686' => 'x86', + ]; + $abiName = $abiMap[$targetArch] ?? 'arm64-v8a'; + // 查找 Android 工程模板目录(优先用项目根的 ext/ui/android,否则用 cwd 下 android/) + $tphpRoot = dirname(__FILE__); + $androidProj = is_dir($tphpRoot . DIRECTORY_SEPARATOR . 'ext' . DIRECTORY_SEPARATOR . 'ui' . DIRECTORY_SEPARATOR . 'android') + ? $tphpRoot . DIRECTORY_SEPARATOR . 'ext' . DIRECTORY_SEPARATOR . 'ui' . DIRECTORY_SEPARATOR . 'android' + : $cwd . DIRECTORY_SEPARATOR . 'android'; + // .so 输出到 cwd/build/android/jniLibs//,gradle 通过 -PtphpJniLibs 读取 + $androidBuildDir = $cwd . DIRECTORY_SEPARATOR . 'build' . DIRECTORY_SEPARATOR . 'android'; + $jniLibsDir = $androidBuildDir . DIRECTORY_SEPARATOR . 'jniLibs' . DIRECTORY_SEPARATOR . $abiName; + if (!is_dir($jniLibsDir)) mkdir($jniLibsDir, 0777, true); + // 保存用户期望的输出基础名(遵循 -o 机制),用于 APK 命名 + // 必须在 outExe 被 .so 路径覆盖前提取 + $apkBaseName = pathinfo($outExe, PATHINFO_FILENAME); + $outExe = $jniLibsDir . DIRECTORY_SEPARATOR . 'libtphp.so'; + } elseif ($isShared) { // -shared 模式:动态库扩展名 $shExt = ($targetOS === 'windows' || ($targetOS === null && PHP_OS_FAMILY === 'Windows')) ? '.dll' : (($targetOS === 'darwin' || ($targetOS === null && PHP_OS_FAMILY === 'Darwin')) ? '.dylib' : '.so'); @@ -1270,8 +1417,16 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { && strpos($extraFlags, 'ui/sokol') !== false) { $extraFlags .= ' -x objective-c'; } -// -shared 模式:生成动态库 -$sharedFlag = $isShared ? ' -shared' : ''; +// -shared 模式:生成动态库(Android 产物强制为 .so 共享库,也需 -shared) +$sharedFlag = ($isShared || $targetOS === 'android') ? ' -shared' : ''; +// 共享库在非 Windows 目标上需要 -fPIC(位置无关代码) +// Windows DLL 不需要 PIC;Linux/macOS/Android 的 .so 必须用 PIC +if ($sharedFlag !== '') { + $isWindowsTarget = ($targetOS === 'windows') || ($targetOS === null && PHP_OS_FAMILY === 'Windows'); + if (!$isWindowsTarget) { + $extraFlags .= ' -fPIC'; + } +} // 项目根目录作为额外 -I 路径,让 ext/ 下的扩展头文件(如 ext/stream/src/stream.h)可被 #include 查找到 $projectRoot = dirname($includeDir); // 注意 -I 顺序:TinyPHP 的 include/ 必须在 $extraFlags(含 mbedtls 的 -I 路径)之前, @@ -1460,6 +1615,315 @@ function ($m) use ($fileDir, $magicExt, $magicInc, $magicCmd) { echo " [YES] {$outExe}\n"; +// Android: 编译成功后自动调用 gradle 打包 APK +if ($targetOS === 'android') { + // ── 多 ABI 编译(用户未显式指定 -arch 时,编译所有 4 个 ABI)── + // 模拟器多为 x86_64,真机多为 arm64。只编译 aarch64 会导致 x86_64 模拟器 + // 无匹配 .so 而 NativeActivity 加载失败秒退。默认编译全部 ABI 覆盖所有设备。 + // 用户显式 -arch xxx 时只编译指定单一 ABI(减小包体或特定设备)。 + if (!$archExplicit) { + $allAndroidAbis = ['aarch64', 'x86_64', 'armv7a', 'i686']; + $extraAbis = array_diff($allAndroidAbis, [$targetArch]); + if (!empty($extraAbis)) { + echo "[*] Multi-ABI build: compiling " . implode(', ', $allAndroidAbis) . " (default; use -arch for single)\n"; + // 递归调用自身编译额外 ABI,复用已转译的 .c 文件 + // 子进程会重新转译(幂等,覆盖同名 .c),但编译产物输出到对应 jniLibs// + $selfScript = __FILE__; + $phpExe = PHP_BINARY; + foreach ($extraAbis as $extraAbi) { + // 构建子进程命令:原参数 + -arch + --no-android-apk(子进程只编译 .so) + // 跳过 $argv[0](脚本自身路径,已用 $selfScript 绝对路径替代) + $subArgs = [$phpExe, $selfScript]; + $skipNextArch = false; + foreach (array_slice($argv, 1) as $a) { + if ($a === $phpExe || $a === $selfScript) continue; + // 跳过已有的 -arch 参数及其值(避免覆盖新的 -arch) + if ($skipNextArch) { $skipNextArch = false; continue; } + if ($a === '-arch') { $skipNextArch = true; continue; } + $subArgs[] = $a; + } + $subArgs[] = '-arch'; + $subArgs[] = $extraAbi; + $subArgs[] = '--no-android-apk'; + $subCmd = escapeshellarg($phpExe); + foreach (array_slice($subArgs, 1) as $a) $subCmd .= ' ' . escapeshellarg($a); + echo " [ABI {$extraAbi}] compiling...\n"; + $subOutput = []; + $subRet = 0; + exec($subCmd . ' 2>&1', $subOutput, $subRet); + // 输出子进程最后几行(关键信息),完整输出太长 + $tail = array_slice($subOutput, -5); + foreach ($tail as $line) echo " " . $line . "\n"; + if ($subRet !== 0) { + echo "[WARN] ABI {$extraAbi} compile failed (skipped). Single-ABI APK may not run on mismatched devices.\n"; + } + } + } + } + // 子进程(--no-android-apk)只编译 .so,跳过 Gradle 打包(主进程统一打包) + if ($skipAndroidApk) { + echo " [YES] {$outExe} (skipped APK, --no-android-apk)\n"; + } else { + echo "[3/3] Building APK via Gradle...\n"; + $gradleDir = $androidProj; + // .so 在 cwd/build/android/jniLibs//(gradle 通过 -PtphpJniLibs 读取) + // APK 输出到 cwd(与其他二进制产物一致),命名遵循 -o 机制,debug 加 -debug 后缀 + $jniLibsRoot = $androidBuildDir . DIRECTORY_SEPARATOR . 'jniLibs'; // cwd/build/android/jniLibs + if (!is_dir($jniLibsRoot)) mkdir($jniLibsRoot, 0777, true); + // 通过 -PtphpJniLibs 让 build.gradle 从 cwd/build/android/jniLibs 读取 libtphp.so + // (不污染 ext/ui/android 模板) + // APK 输出由 Gradle 默认路径生成,构建后由 tphp.php 复制到 cwd/ + // (在 Gradle 中重定向 outputDirectory 会触发 AGP ListingFileRedirectTask bug) + $propArg = '-PtphpJniLibs=' . escapeshellarg($jniLibsRoot); + + if (!is_dir($gradleDir)) { + echo "[WARN] Android project template not found, skip APK packaging.\n"; + echo " .so is ready at: {$outExe}\n"; + } else { + // ── 0. 检测 Java 版本,不兼容时自动搜索并切换到 Java 17/21 LTS ── + // Gradle 8.9 + AGP 8.7.0 官方支持 Java 8~23,Java 24+ 会导致 AGP 内部 + // XML 解析器(用于扫描 SDK platforms)静默失败,报 "Failed to find target"。 + $javaHome = getenv('JAVA_HOME'); + $javaExe = $javaHome ? $javaHome . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'java' : 'java'; + if ($javaHome && PHP_OS_FAMILY === 'Windows') $javaExe .= '.exe'; + $javaMajor = 0; + if (file_exists($javaExe) || $javaExe === 'java') { + exec('"' . $javaExe . '" -version 2>&1', $jOut, $jRet); + $jLine = implode("\n", $jOut); + if (preg_match('/version "(\d+)(?:\.(\d+))?(?:\.\d+)?"/', $jLine, $jm)) { + $javaMajor = (int)$jm[1] === 1 ? (int)($jm[2] ?? 0) : (int)$jm[1]; + } + } + // Java 24+ 或未知版本 → 搜索兼容 JDK(17 或 21 LTS) + if ($javaMajor > 23 || $javaMajor < 8) { + if ($javaMajor > 0) { + echo " [!] Java {$javaMajor} incompatible with Gradle 8.9 (supports 8~23).\n"; + } + // 搜索 JDK 21 和 17(优先 21 LTS) + $jdkDirs = []; + if (PHP_OS_FAMILY === 'Windows') { + foreach (['C:\\env', 'C:\\Program Files\\Java', 'C:\\Program Files\\Eclipse Adoptium'] as $base) { + if (is_dir($base)) { + foreach (glob($base . '\\jdk-21*', GLOB_ONLYDIR) ?: [] as $d) $jdkDirs[] = $d; + foreach (glob($base . '\\jdk-17*', GLOB_ONLYDIR) ?: [] as $d) $jdkDirs[] = $d; + } + } + } elseif (PHP_OS_FAMILY === 'Darwin') { + foreach (glob('/Library/Java/JavaVirtualMachines/*/Contents/Home', GLOB_ONLYDIR) ?: [] as $d) { + if (str_contains($d, '-21.') || str_contains($d, '-17.')) $jdkDirs[] = $d; + } + } else { + foreach (glob('/usr/lib/jvm/java-2{1,7}*', GLOB_ONLYDIR | GLOB_BRACE) ?: [] as $d) $jdkDirs[] = $d; + } + if (!empty($jdkDirs)) { + // 优先选 21,其次 17 + usort($jdkDirs, fn($a, $b) => (str_contains($b, '21') ? 1 : 0) - (str_contains($a, '21') ? 1 : 0)); + $javaHome = $jdkDirs[0]; + putenv('JAVA_HOME=' . $javaHome); + echo " [YES] Switched to Java: {$javaHome}\n"; + } else { + echo " No compatible JDK (17/21) found. Install Java 21 LTS.\n"; + if (PHP_OS_FAMILY === 'Windows') { + echo " Example: setx JAVA_HOME \"C:\\env\\jdk-21\"\n"; + } else { + echo " Example: export JAVA_HOME=/usr/lib/jvm/java-21\n"; + } + } + } + // ── 1. 检测 Android SDK,生成 local.properties ── + $sdkRoot = getenv('ANDROID_HOME') ?: getenv('ANDROID_SDK_ROOT'); + if (!$sdkRoot) { + $defaultSdk = match(PHP_OS_FAMILY) { + 'Windows' => getenv('LOCALAPPDATA') . '\\Android\\Sdk', + 'Darwin' => getenv('HOME') . '/Library/Android/sdk', + default => getenv('HOME') . '/Android/Sdk', + }; + if (is_dir($defaultSdk)) $sdkRoot = $defaultSdk; + } + if ($sdkRoot && is_dir($sdkRoot)) { + $localProps = $gradleDir . DIRECTORY_SEPARATOR . 'local.properties'; + $sdkPath = str_replace('\\', '/', $sdkRoot); + file_put_contents($localProps, "sdk.dir={$sdkPath}\n"); + echo " [YES] SDK: {$sdkRoot}\n"; + // 自动接受所有 SDK 许可(避免 Gradle 构建时报 "licenses not accepted") + // Android SDK 许可哈希是 Google 公开的固定值,写入 licenses 目录即表示接受 + // 参考:https://developer.android.com/studio/intro/update.html#download-with-gradle + $licensesDir = $sdkRoot . DIRECTORY_SEPARATOR . 'licenses'; + if (!is_dir($licensesDir)) mkdir($licensesDir, 0777, true); + // Google 公开的许可哈希(写入这些文件等于接受所有 SDK 许可) + // 注意:每个哈希前必须有 \n,末尾也必须有 \n,否则 Gradle 解析失败 + // 参考:https://developer.android.com/studio/intro/update.html#download-with-gradle + // - 8933... API < 28 旧许可 + // - 2433... API 28~33 许可 + // - d56f... API 34+ 新增许可(AGP 8.x 内部依赖 android-34 / build-tools 34.0.0) + $licenseHashes = [ + 'android-sdk-license' => "\n8933bad161af4178b1185d1a37fbf41ea5269c55\n24333f8a63b6825ea9c5514f83c2829b004d1fee\nd56f5187479451eabf01fb78af698cb\n", + 'android-sdk-preview-license' => "\n84831b9409646a918e30573bab4c9c91346d8abd\n", + 'android-sdk-arm-dbt-license' => "\n859f317696f67ef3d7f30a50a5560e7834e282e0\n", + 'android-googletv-license' => "\n601085b94cd77f0b54ff86406907032723ea9580\n", + 'mips-android-sysimage-license' => "\ne9acab5b5fbb560a72cfaecf8947a6ab\n", + 'google-gdk-license' => "\n33b6a2b6a071c3892a5825047dd9a31d\n", + 'intel-android-extra-license' => "\nd975f751698a77b662f1254ddbeed3901e7634aa\n", + ]; + $needWrite = false; + foreach ($licenseHashes as $name => $hash) { + $licFile = $licensesDir . DIRECTORY_SEPARATOR . $name; + // 若文件不存在或内容不包含所有需要的哈希,则重写 + // (旧版本可能缺少 d56f... 哈希,需要补写) + if (!file_exists($licFile) || strpos((string)file_get_contents($licFile), 'd56f5187479451eabf01fb78af698cb') === false && $name === 'android-sdk-license') { + file_put_contents($licFile, $hash); + $needWrite = true; + } + } + // 清理 Gradle 下载中断残留的 -N 后缀目录(如 android-35-3 → android-35) + // 问题:Gradle 下载时若目标目录已存在(不完整),会装到 android-35-N, + // 但 -N 目录的 package.xml 中 path 仍是 "platforms;android-35", + // 导致多个目录声称是同一个包,AGP 扫描时冲突并拒绝识别 android-35。 + // 另一类残留:目录只有 .installer/.installData(无 package.xml), + // 是 Gradle 下载中断的空壳,AGP 扫描到会污染 SDK 仓库状态。 + foreach (['platforms', 'build-tools'] as $subDir) { + $base = $sdkRoot . DIRECTORY_SEPARATOR . $subDir; + if (!is_dir($base)) continue; + $items = glob($base . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR); + if ($items === false) continue; + // 完整性判断:platforms 需 android.jar + source.properties;build-tools 需 aapt2 + source.properties + $isComplete = function (string $dir) use ($subDir): bool { + if (!file_exists($dir . DIRECTORY_SEPARATOR . 'source.properties')) return false; + if ($subDir === 'platforms') { + return file_exists($dir . DIRECTORY_SEPARATOR . 'android.jar'); + } + return file_exists($dir . DIRECTORY_SEPARATOR . 'aapt2.exe') + || file_exists($dir . DIRECTORY_SEPARATOR . 'aapt2'); + }; + foreach ($items as $item) { + $name = basename($item); + $pkgXml = $item . DIRECTORY_SEPARATOR . 'package.xml'; + if (!file_exists($pkgXml)) { + // 无 package.xml 但有 .installer/ → 下载中断的空壳目录,直接删除 + if (is_dir($item . DIRECTORY_SEPARATOR . '.installer')) { + if (deleteDirectory($item)) { + echo " [YES] Deleted incomplete {$subDir}/{$name} (no package.xml)\n"; + } + } + continue; + } + $content = (string)file_get_contents($pkgXml); + // 解析 path="platforms;android-35" 或 path="build-tools;35.0.0" + if (!preg_match('/path="[^;]+;([^"]+)"/', $content, $pm)) continue; + $expectedName = $pm[1]; + if ($expectedName === $name) continue; // 目录名与 path 一致,正常目录 + // 目录名与 path 不一致 → 是 -N 重复目录 + $targetPath = $base . DIRECTORY_SEPARATOR . $expectedName; + if (is_dir($targetPath) && $isComplete($targetPath)) { + // 目标完整 → 删除 -N 重复目录(消除 path 冲突) + if (deleteDirectory($item)) { + echo " [YES] Deleted duplicate {$name} ({$expectedName} is complete)\n"; + } + } elseif ($isComplete($item)) { + // 目标不存在/不完整,-N 完整 → 用 -N 替换目标 + if (is_dir($targetPath)) deleteDirectory($targetPath); + if (@rename($item, $targetPath)) { + echo " [YES] Replaced {$expectedName} with {$name}\n"; + } + } + } + } + // 清理 SDK 根下的 .temp 目录(Gradle 下载临时目录,中断后残留) + $sdkTemp = $sdkRoot . DIRECTORY_SEPARATOR . '.temp'; + if (is_dir($sdkTemp)) { + if (deleteDirectory($sdkTemp)) { + echo " [YES] Cleaned .temp directory\n"; + } + } + } else { + $sdkExample = match(PHP_OS_FAMILY) { + 'Windows' => " setx ANDROID_HOME \"%LOCALAPPDATA%\\Android\\Sdk\"\n", + 'Darwin' => " export ANDROID_HOME=~/Library/Android/sdk\n", + default => " export ANDROID_HOME=~/Android/Sdk\n", + }; + echo " [!] Android SDK not found. APK packaging skipped.\n"; + echo " Android SDK is required for Gradle to build APK.\n"; + echo " Install via Android Studio SDK Manager, then set ANDROID_HOME env var.\n"; + echo $sdkExample; + echo " .so is ready at: {$outExe}\n"; + $sdkRoot = false; + } + + // ── 2. 执行 gradle 打包 ── + if (!empty($sdkRoot)) { + // 清理项目下的 .gradle/ 配置缓存(不影响 ~/.gradle/caches/ 中的依赖缓存) + // AGP 会缓存 SDK 状态到此处,若 SDK 曾有不完整/重复目录,缓存会导致 + // "Failed to find target" 持续报错,即使 SDK 已修复 + $configCache = $gradleDir . DIRECTORY_SEPARATOR . '.gradle'; + if (is_dir($configCache)) { + deleteDirectory($configCache); + } + + // SDK 根的 source.properties(platform-tools 元数据)会干扰 AGP 的 + // LegacyLocalRepoLoader,导致 SDK 扫描器将根目录当作一个 package, + // 无法发现 platforms/ 下的平台包,报 "Failed to find target"。 + // 临时重命名,Gradle 构建结束后恢复。 + $rootProps = $sdkRoot . DIRECTORY_SEPARATOR . 'source.properties'; + $rootPropsBak = $rootProps . '.tphp_bak'; + $rootPropsRenamed = false; + if (file_exists($rootProps) && !file_exists($rootPropsBak)) { + @rename($rootProps, $rootPropsBak); + $rootPropsRenamed = true; + } + + $gradlew = PHP_OS_FAMILY === 'Windows' ? 'gradlew.bat' : './gradlew'; + $useWrapper = file_exists($gradleDir . DIRECTORY_SEPARATOR . $gradlew); + $apkCmd = ''; + if ($useWrapper) { + // --no-daemon: 避免 Daemon 缓存旧的 SDK 状态(首次构建时 SDK 可能刚下载完) + $apkCmd = escapeshellarg($gradlew) . ' assembleDebug --no-daemon ' . $propArg; + } else { + exec('gradle --version 2>&1', $gOut, $gRet); + if ($gRet === 0) { + $apkCmd = 'gradle assembleDebug --no-daemon ' . $propArg; + } + } + if ($apkCmd === '') { + echo " [WARN] No gradle found. .so is at: {$outExe}\n"; + echo " Install gradle or run: cd \"{$gradleDir}\" && gradlew assembleDebug {$propArg}\n"; + // 恢复 source.properties + if ($rootPropsRenamed && file_exists($rootPropsBak)) { + @rename($rootPropsBak, $rootProps); + } + } else { + if (PHP_OS_FAMILY === 'Windows') $apkCmd = 'cd /d ' . escapeshellarg($gradleDir) . ' && ' . $apkCmd; + else $apkCmd = 'cd ' . escapeshellarg($gradleDir) . ' && ' . $apkCmd; + system($apkCmd, $apkRet); + // 恢复 source.properties(无论构建成功与否) + if ($rootPropsRenamed && file_exists($rootPropsBak)) { + @rename($rootPropsBak, $rootProps); + } + if ($apkRet === 0) { + // Gradle 默认输出到 /app/build/outputs/apk/debug/app-debug.apk + $defaultApk = $gradleDir . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'build' + . DIRECTORY_SEPARATOR . 'outputs' . DIRECTORY_SEPARATOR . 'apk' . DIRECTORY_SEPARATOR + . 'debug' . DIRECTORY_SEPARATOR . 'app-debug.apk'; + // APK 放到 cwd,命名遵循 -o 机制:debug 加 -debug 后缀 + $targetApk = $cwd . DIRECTORY_SEPARATOR . $apkBaseName . '-debug.apk'; + if (file_exists($defaultApk)) { + // 复制 APK 到 cwd/(不移动,保留 Gradle 默认产物供增量构建) + copy($defaultApk, $targetApk); + echo " [YES] {$targetApk}\n"; + echo " Install: adb install \"{$targetApk}\"\n"; + } else { + echo " [YES] APK built (see {$defaultApk})\n"; + } + } else { + echo " [NO] Gradle build failed (exit {$apkRet}).\n"; + echo " .so is ready at: {$outExe}\n"; + exit(1); + } + } + } + } + } // end else (not $skipAndroidApk) +} + // --debug: run binary and compare expected vs actual output if ($debugMode) { $debugLines = !empty($allDebugs) ? $allDebugs : $merged->debugs; @@ -1571,6 +2035,32 @@ function extractArMembers(string $aFile, string $outDir): array return $extracted; } +// ============================================================ +/** 递归删除目录(用于清理 SDK 中重复的 -N 后缀目录和 Gradle 缓存) */ +function deleteDirectory(string $dir): bool +{ + if (!is_dir($dir)) return false; + // Windows 上某些文件可能有只读属性,用 cmd rmdir /s /q 最可靠 + if (PHP_OS_FAMILY === 'Windows') { + $cmd = 'rmdir /s /q "' . $dir . '" 2>nul'; + exec($cmd, $out, $ret); + return !is_dir($dir); + } + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $fileinfo) { + $path = $fileinfo->getPathname(); + if ($fileinfo->isDir()) { + @rmdir($path); + } else { + @unlink($path); + } + } + return @rmdir($dir); +} + // ============================================================ /** @param string[] $args * @return array{0: string[], 1: string[]} */ @@ -1663,8 +2153,9 @@ function showHelp(): never Options: -o output file path (default: named after entry file) -cc specify C compiler (default: built-in TCC) - -os cross-compile target: windows, linux, macos + -os cross-compile target: windows, linux, macos, android -arch target architecture: x86_64, aarch64 (default: host) + TPHP_SYSROOT env: sysroot path for clang -target cross-compile (e.g. \\wsl$\Ubuntu) -shared compile as shared library (.dll/.so/.dylib) --debug print full compile command --ssa enable SSA IR pipeline (FlatAst → SSA → optimize → C) @@ -1681,6 +2172,7 @@ function showHelp(): never tphp main.php -os linux -arch aarch64 (ARM64 Linux) tphp main.php -os windows -cc gcc (x86_64 Windows via mingw) tphp lib.php -shared -o mylib.dll (shared library with #[Export]) + tphp . -os android (Android APK, auto gradle build) HELP; exit(0);