不用学 Dart 和 Flutter,PHP 现在可以直接跑在手机上

PHP 写了二十多年的服务端,中间被宣告死亡过很多次。它现在仍然支撑着全球超过七成的网站,WordPress、Wikipedia、Laravel 都在这个名单里。PHP 8 之后补上严格类型和 JIT,执行速度也早就不是当年那个水平了。

Phphone 是这堆争论之外的一个开源项目。它把 PHP 8.4 的解释器编译进手机,让后端逻辑直接跑在用户的设备上。整套技术栈只有 PHP 8.4、HTML、CSS 和 JavaScript,没有 Flutter、React Native、Electron,也没有 node_modules。发布一个 App 不必先学 Dart,更不用把已经跑顺的 Composer 换成另一套依赖体系。

后端跑在设备上,不是网页套壳

混合应用的主流做法是 WebView 画界面、业务逻辑留在服务器。Phphone 把这两层的关系掉了个头,WebView 只管显示,执行发生在它背后。

text
HTML / CSS / JS 界面层
        ↓  window.Kie / KieBridge
Chromium / WebKit WebView(仅作为显示层)

Kie Engine(C++ 运行时)
    ├── PHP 8.4 解释器,常驻共享内存
    ├── 内置 SQLite3 数据库
    └── Android / iOS 原生桥接

http://127.0.0.1:8081   设备内部回环服务

几个概念先说明白:

  • Phphone:框架与编译器本身
  • Kie Engine:C++ 写的核心引擎,内置预编译的 PHP 二进制和原生通信桥,启动时往 JavaScript 里注入全局的 window.Kie
  • Dual WebView:两层叠加的浏览器,前层是承载 HTML/PHP 应用的透明窗口,后层用来加载外部网页,绕开安全策略拦截
  • KieBridge:JavaScript 和 Android/iOS 之间的通信通道

所以它和网页套壳是两件事。套壳方案的重逻辑必须留在云端,Phphone 把它塞进了手机:屏幕上是 WebView,屏幕背后是 C/C++ 引擎托着的一整个 PHP 8.4 解释器和一个嵌入式 SQLite3,并且能直接调硬件。

不到 20 MB 里装了什么

Phphone 的取舍集中在体积、依赖和源码保护三件事上,而且都做得比较极端。

基础引擎不到 15 MB。自带那个硬件诊断示例应用整包也不到 20 MB,里面已经包含 PHP 运行时、SQLite 和演示页面。作为对照,一个空白的 React Native Hello World 大约 35 MB,Flutter 大约 25 MB。

起步也不需要任何配置。没有 Webpack,没有 Babel,把 index.php 丢进 src/ 目录就是一个能跑的移动应用。默认脚手架给的也不是计数器示例,而是一个完整的硬件诊断面板,可以直接试手电筒、GPS 和摄像头,另外附一个实时 CSS 渐变生成器 newgradient.php

设备的本地服务器走 http://127.0.0.1:8081,应用全程不需要联网就能执行。

源码和数据的保护是这个项目比较少见的卖点。PHP 借助 OpenSSL 在内存里加密源码,.php 文件以 AES-256 加密后注入 .apk.ipa;本地 SQLite 数据库同样可以加密,即使攻击者拿到设备的 root 权限,也读不到用户的私有数据。

硬件调用写在 PHP 里

硬件能力统一挂在 Phphone\Device 这个静态门面上(对应文档里的 Phase 1.0)。不用装 SDK,也不用写 Kotlin 或 Swift。

php
<?php
use Phphone\Device;

// 无需 require_once,C++ 运行时已全局注册该类

// 调用原生相机拍摄高清照片
$base64Image = Device::takeCameraPicture();

// 获取精确的 GPS 坐标
$location = Device::getGpsLocation();
echo $location['lat'] . ", " . $location['lng'];

// 将敏感凭证写入原生 Keychain / Keystore
Device::secureWrite("api_token", "super_secret_token_123");

也可以把这些硬件方法包成 PHP 接口,再由 JavaScript 用 fetch() 异步调用。

目前可用的原生能力大致覆盖这些范围:相机与相册、GPS 定位、分页读取的通讯录、陀螺仪和加速度计这类高频传感器、Firebase 推送、StoreKit 与 Google Play Billing 的应用内购买、麦克风录音与播放、文档选择器、本地通知、Keychain/Keystore 与生物识别、应用内嵌浏览器、系统分享面板、电池与网络状态、剪贴板、手电筒与振动,以及后台执行 PHP 的守护进程。

权限在用到的时候才申请

Phphone 采用 Just-In-Time 最小权限模型,这里的 JIT 说的是权限申请时机,和 PHP 8 的 JIT 编译器没有关系。应用启动时不会弹一屏权限请求,只有开发者或用户真正触发某个硬件功能时才会申请。

php
use Phphone\Device;

// 第一步:申请原生权限(Android 13+ / iOS)
$permissionGranted = Device::requestNotificationPermission();

if ($permissionGranted) {
    // 第二步:授权通过后派发通知
    Device::notification("欢迎", "感谢开启推送提醒");
} else {
    // 被拒绝时,给出不打扰用户的原生 toast
    Device::toast("通知已被用户在系统设置中关闭");
}

相机也是同样的模式,可以显式预校验,也可以直接调用、由 Device::camera() 在未授权时自动弹出申请:

php
use Phphone\Device;

// 方式 A:显式预校验
if (Device::requestPermission('camera')) {
    $photoBase64 = Device::camera();
    if ($photoBase64) {
        Device::toast("照片拍摄成功");
    }
} else {
    Device::toast("需要相机权限");
}

// 方式 B:直接调用,Device::camera() 会在未授权时自动弹出申请
$photoBase64 = Device::camera();

Device::requestPermission($type) 支持的类型和对应的原生实现如下:

类型 $typeAndroid 原生权限iOS 原生框架说明
'notifications'POST_NOTIFICATIONSUNUserNotificationCenter本地与推送通知
'gps'ACCESS_FINE_LOCATIONCoreLocation精确 GPS 坐标
'camera'CAMERAAVFoundation高清相机拍摄
'microphone'RECORD_AUDIOAVAudioSession音频录制
'contacts'READ_CONTACTSContacts原生通讯录
'storage'READ_EXTERNAL_STORAGE / PickerUIDocumentPicker本地文件系统与媒体
'biometric'BiometricPromptLocalAuthenticationFace ID、Touch ID 与指纹

用第二层浏览器加载外部网页

在应用里加载外部网页(比如 Google 或者某个支付网关)时,iframe 会撞上 CORS 和 X-Frame-Options,页面往往直接白屏。Phphone 的办法是在透明的 HTML 界面背后压一个隔离的原生浏览器。

引擎启动时会自动向全局 window 注入 Kie 对象,不需要装任何 SDK。iOS 和 Android 的原生派发语法略有差异,官方建议先写一层统一包装:

javascript
function callNativeBrowser(action, params = {}) {
    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
    if (isIOS) {
        if (window.webkit?.messageHandlers?.Kie) {
            window.webkit.messageHandlers.Kie.postMessage({ action, ...params });
        }
    } else {
        if (window.Kie && typeof window.Kie[action] === 'function') {
            if (action === 'loadUrl') window.Kie.loadUrl(params.url);
            if (action === 'setBrowserActive') window.Kie.setBrowserActive(params.active);
            if (action === 'setBrowserMargins') window.Kie.setBrowserMargins(params.top, params.bottom);
            if (action === 'setUiRects') window.Kie.setUiRects(params.rectsJson);
            if (action === 'startDaemon') window.Kie.startDaemon(JSON.stringify(params));
        }
    }
}

用的时候激活背景浏览器、传入地址,再把页面背景调成透明:

javascript
// 1. 激活背景原生浏览器
callNativeBrowser('setBrowserActive', { active: true });

// 2. 加载目标外部地址
callNativeBrowser('loadUrl', { url: 'https://google.com' });

// 3. 让应用背景透明
document.body.style.backgroundColor = 'transparent';

顶部如果有 60px 的标题栏,不想让原生浏览器压到它下面,用边距让出来:

javascript
// 顶部预留 60px,底部不预留
callNativeBrowser('setBrowserMargins', { top: 60, bottom: 0 });

Web 应用相当于盖在原生浏览器上的一层透明窗口,触摸事件会被它拦住。把可交互 UI 元素的矩形范围交给 Phphone,范围之外的触摸就会直接穿透到背后的浏览器:

javascript
// 传入当前可交互 UI 组件的边界矩形
callNativeBrowser('setUiRects', {
    rectsJson: JSON.stringify([
        { left: 0, top: 0, right: window.innerWidth, bottom: 60 }
    ])
});

原生浏览器滚动时会广播 nativeScroll 事件,可以拿来做动态菜单:

javascript
window.addEventListener('nativeScroll', (e) => {
    const dy = e.detail.dy; // 向下滚动为正,向上滚动为负
    if (dy > 10) console.log("隐藏标题栏");
});

用完记得关掉(active: false),并把 <body> 的背景色恢复成实色,例如 background-color: white;

让界面看起来像原生应用

界面是横跨整屏渲染的,布局得自己给刘海和系统手势条留位置。设备行为可以直接在终端里配置,不用去改 Kotlin 或 Swift 源文件:

bash
# 屏幕方向
phphone config orientation portrait   # 锁定竖屏(社交信息流、表单)
phphone config orientation landscape  # 锁定横屏(游戏、视频播放)
phphone config orientation auto       # 允许动态旋转(默认)

# 双指缩放
phphone config zoom off               # 关闭双指缩放(推荐)
phphone config zoom on                # 开启缩放(无障碍支持)

HTML 的 <head> 里要用 viewport-fit=cover,CSS 才能算出安全区:

html
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">

样式表里用引擎提供的环境变量给标题栏和导航栏补内边距:

css
.header {
    /* 动态顶部内边距,避让摄像头刘海 */
    padding-top: env(safe-area-inset-top, 20px);
}

.bottom-navbar {
    /* 底部内边距,避让系统手势条 */
    padding-bottom: env(safe-area-inset-bottom, 20px);
}

再补一段基础样式,触摸响应会自然一些:

css
body {
    /* 避免长按误选文字 */
    -webkit-user-select: none;
    user-select: none;

    /* 去除移动端点击时的灰色高亮块 */
    -webkit-tap-highlight-color: transparent;

    /* 禁止弹性回弹式的过度滚动 */
    overscroll-behavior-y: none;
}

推送通知的行为由后端 JSON 决定

推送走 Firebase Cloud Messaging,入口是 Phphone\Device::getPushToken()。通知的累积、覆盖和分组行为全部由后端(PHP 或 Node.js)发出去的 JSON 载荷决定,不需要动 Kotlin 或 Swift。

注册 Firebase 时有个硬性要求:Android Package Name 或 iOS Bundle ID 必须和 Phphone 的包标识完全一致,否则 Gradle 和 Xcode 会直接拒绝这份配置。包 ID 不一致时,改 android/app/build.gradle.kts 里的 applicationId,以及 ios/project.ymlphphone_meta.json 里的 bundleId

凭据文件的放置位置是固定的:Android 侧把 google-services.json 放进 android/app/google-services.json,编译器构建时会自动检测到;iOS 侧把 GoogleService-Info.plist 放进 ios/App/GoogleService-Info.plist。后端派发推送要用的 Admin SDK 私钥(firebase-adminsdk-*.json)必须留在生产服务器上。

载荷支持的字段:

字段类型说明原生行为
titlestring通知标题以粗体显示标题文字
bodystring通知正文主要描述文本
tag / idstring可选,唯一替换标识存在时替换/更新同一 ID 的旧通知;省略时通知累积堆叠
group / thread_idstring可选,分组键将通知打包(Android 的 Group Summary、iOS 的 threadIdentifier
route / urlstring可选,应用内导航路由作为 intent / userinfo 参数传入,用于点击后跳转
replyboolean / string可选,直接回复(true在通知内附加原生输入框与“回复”按钮

四种典型场景的写法差别其实只在几个字段。

想要通知一条条堆在状态栏里,省略 tag 就行:

json
{
  "notification": {
    "title": "任务提醒",
    "body": "你有一场 15:00 的会议"
  }
}

订单进度这类需要原地更新的,给一个固定的 tagid,同一 tag 的通知会覆盖上一条,比如从“备货中”变成“配送中”:

json
{
  "notification": {
    "title": "订单状态 #1052",
    "body": "你的订单正在派送中 🚚"
  },
  "data": {
    "tag": "order_1052"
  }
}

想做成 WhatsApp 或 Gmail 那样的会话折叠,让多条通知共享同一个 group,各自的 tag 保持不同:

json
{
  "notification": {
    "title": "Carlos Ramirez",
    "body": "嘿,你有五分钟吗?"
  },
  "data": {
    "group": "chat_carlos_99",
    "tag": "msg_1001"
  }
}
json
{
  "notification": {
    "title": "Carlos Ramirez",
    "body": "你看过项目草稿了吗?"
  },
  "data": {
    "group": "chat_carlos_99",
    "tag": "msg_1002"
  }
}

两条消息在 Android 和 iOS 上都会归到“Carlos Ramirez(2 条消息)”下面,用户可以展开看,也可以分开点。

data 里带上 "reply": true,系统会给通知装一个输入框和“回复”按钮,用户不用打开应用就能回:

json
{
  "notification": {
    "title": "客户支持",
    "body": "你的问题解决了吗?"
  },
  "data": {
    "reply": true,
    "tag": "ticket_501"
  }
}

点击通知时,应用会被平滑地带回前台,WebView 不会被销毁,启动屏也不会重新触发。

从安装到上架

它编译出来的是原生应用,所以本机得有完整的工具链:终端里的 PHP 8.0+ 用来跑 CLI,Android 侧需要装好 Android SDK 和模拟器的 Android Studio,iOS 侧需要一台装了 Xcode 和命令行工具的 Mac。

CLI 有三种装法:

bash
# 方式 A:全局自动安装(macOS / Linux,推荐)
curl -sS https://phphone.xyz/install.sh | bash
powershell
# 方式 A(Windows):以管理员身份打开 PowerShell 后运行
irm https://phphone.xyz/install.ps1 | iex
bash
# 方式 B:通过 Composer 创建项目
composer create-project phphone/phphone my-store
cd my-store
php cli/bin/phphone run
bash
# 方式 C:手动克隆并初始化
git clone https://github.com/phphone/phphone.git my-store
cd my-store/cli
composer install
cd ..
php cli/bin/phphone run

日常开发的循环就是几条命令:

bash
# 创建项目
phphone create "My Store" com.mystore.app

# 运行与热重载
cd my-store
phphone run

# 生成图标与启动屏
phphone setup

# 构建生产包
phphone build apk --release

# 为商店提交签名
phphone sign --keystore my-release-key.jks

phphone run 会自动找到正在跑的模拟器、把应用启起来,保存文件后立刻对 PHP、JavaScript 和 CSS 做热重载。图标和启动屏只要把 icon.pngsplash.png 放进 setup/ 再执行 phphone setup,各原生密度的多尺寸资源会自动生成并注入。

完整的命令清单:

命令说明
create <name> <pkg>生成新的 Phphone 项目
run构建并启动应用,支持即时热重载
setup生成并注入自定义图标与启动屏
build <target>编译生产二进制(APK、AAB、IPA)
sign为商店分发生成发布包签名
rename安全地更新应用显示名与包标识
doctor诊断缺失的环境工具链与依赖
logs实时流式输出原生日志(Logcat / Console)
devices列出已连接的实体设备与模拟器
screenshot从已连接设备截取高分辨率屏幕
clean清理原生构建产物与 Gradle 缓存
stop终止设备上正在运行的应用进程
uninstall从目标设备卸载应用

现代前端工具链和完整框架都能用

原生外壳跑的是现代浏览器引擎,所以 Vue、React、Tailwind、Bootstrap 这些前端框架都能正常工作,官方文档尤其推荐 Lit.js 和原生 Web Components,认为它们最贴合 Phphone 的轻量路线。如果要在老一点的 Android 设备上用很新的 JS API,照传统网站的做法注入 polyfill 即可。

用 TypeScript、Vite、Tailwind CLI 或 Webpack 的项目,流程是先在 PC 上构建,把产物输出到 src/js/ 这类资源目录,再用 .phphoneignore 把开发期文件排除掉:

text
# .phphoneignore
node_modules/
package.json
package-lock.json
tsconfig.json
vite.config.js
src_ts/
tests/
.git/

执行 phphone runphphone build apk --release 时,编译器会读这个文件、丢掉开发开销,只打包干净的 JavaScript 和 PHP 后端,最终生产包能控制在 20 MB 以内。

路由方面,本地后端虽然监听 http://127.0.0.1:8081,但不需要在标记里硬编码这个 IP。按标准相对路径写就行,例如 <a href="/newgradient.php">,原生容器会自己解析,端口变了代码也不用改。

后端这边的限制更需要留意:composer 可以正常使用,得益于 C++ 运行时的效率,Laravel、Symfony 这类完整框架可以直接跑在用户的手机上,不需要任何远程服务器。但依赖包必须是纯 PHP,任何需要本机 C 编译的依赖都和嵌入式运行时不兼容。

三个必须先知道的坑

嵌入式运行时和常规 PHP 环境的差别不小,有三个地方踩了会直接崩。

exit 和 die 会让应用崩溃

Phphone 把 C++ 的 PHP Zend 核心以持久共享内存的方式维护(通过 NanoHTTPD / GCDWebServer),不像 Apache 那样每次请求结束就销毁工作进程。所以致命错误的表现完全不同:Zend 引擎一旦遇到无法恢复的状态就会触发 Zend Bailout(C 层的 longjmp),它会直接终止原生工作线程,应用随之崩溃。

写后端时有三条必须遵守:

  1. 绝不要用 exit;exit();die();,这些调用会立刻触发崩溃,控制流程请用 return 或抛异常
  2. 把 API 处理器包进 try/catch,别让未捕获的异常或致命错误(调用未定义函数、类型不匹配等)冒到顶层,捕获之后向前端返回结构化的 JSON 错误
  3. 处理大数据集时用 set_time_limit(0);,避免 Zend 内部超时中断

生产包里文件系统是只读的

构建 Android APK 时,项目目录内的所有文件(以及 PHP 的 __DIR__)都会作为只读资源打包。SQLite 数据库绝对不能放在 __DIR__ 里,直接在那里打开或初始化数据库必然失败。官方给的路径解析方式是这样:

php
// Phphone 官方推荐的 SQLite 路径解析模式
function getDB() {
    // 1. 开发/热重载模式下,优先尝试可写的项目本地路径
    $dataDir = __DIR__ . '/../../data';

    // 2. 若为只读环境(生产 APK 或 iOS IPA),解析原生系统存储目录
    if (!is_writable(__DIR__)) {
        $temp = rtrim(sys_get_temp_dir(), '/\\');
        if (strpos($temp, 'cache') !== false) {
            $dataDir = dirname($temp) . '/files/app_data'; // Android 生产环境
        } else {
            $dataDir = dirname($temp) . '/Documents/app_data'; // iOS 生产环境 / 回退
        }
    }

    if (!is_dir($dataDir)) {
        @mkdir($dataDir, 0777, true);
    }

    $dbPath = $dataDir . '/database.sqlite';
    $pdo = new PDO('sqlite:' . $dbPath);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    return $pdo;
}

同样的原因也会影响框架。Laravel 能跑,但要把它内部的存储路径(storage/var/cache/)重定向到系统可写的 data 目录,否则应用会因权限错误直接停摆。

php
// Laravel 配置示例(bootstrap/app.php)
$app = new Illuminate\Foundation\Application($_ENV['APP_BASE_PATH'] ?? dirname(__DIR__));
// 把 storage 路径重定向到可写的系统临时目录
$app->useStoragePath(sys_get_temp_dir() . '/laravel_storage');

Release 模式下 POST 请求体是空的

Release 模式(带原生源码加密的生产 APK)下,流量会被路由到 http://kie.local。受 Android WebView 的安全约束,原生网络拦截会把 POST 请求的请求体丢掉,结果是 AJAX 或 fetch() 发出去的 POST 到了 PHP 这边,$_POSTphp://input 都是空的。

官方的绕行办法是把数据用 GET 传,把 JSON 编码进查询参数:

javascript
fetch('api.php?data=' + encodeURIComponent(JSON.stringify(payload)))

不要把密钥硬编码进客户端

源码有 AES-256 加密,但官方文档用警告框强调了一条更要紧的规矩:数据库管理密码、Stripe 或 AWS 的密钥、主认证令牌这类东西,绝不能硬编码进 PHP 代码或 .env 文件。

原因不难理解。不管用 Phphone、Flutter、Swift 还是 Kotlin,移动端二进制都可能被反编译,硬编码的字符串都会暴露。稳妥的做法是让客户端只当消费方:扣款、查企业内部服务这类关键操作,通过 HTTPS 转发给远程 REST API,凭据留在服务器上;本地要存敏感数据,先用 PHP 的 openssl_encrypt 加密再写进 SQLite。

后台任务

移动端的后台任务通常比较麻烦,Phphone 把它拆成了三步:JavaScript 先让系统派生一个后台守护进程;系统侧维持一个后台工作者(Android 的 ForegroundService 或 iOS 的 BGTaskScheduler),每隔一段时间向本地 PHP 服务器发一次不可见的 HTTP 查询;PHP 脚本被唤醒、执行逻辑,然后回去休眠。

标准 Phphone 应用里,引擎默认指向项目根目录的 src/daemon.php

javascript
callNativeBrowser('startDaemon', { taskName: 'sync_data', interval: 60 });
php
<?php
$task = $_GET['task'] ?? 'unknown';
// 业务逻辑写在这里,例如把本地 SQLite 记录同步到远端服务器

嵌入完整 MVC 框架时,根目录的 daemon.php 会把路由搞乱。这时候给一个自定义 endpoint,让后台轮询打到框架的路由器上,例如 public/index.php

javascript
callNativeBrowser('startDaemon', {
    taskName: 'sync_data',
    interval: 60,
    endpoint: '/api/background-tasks' // 原生系统会 ping 这个 Laravel 路由
});
php
// routes/api.php
Route::get('/background-tasks', function(Request $request) {
    if ($request->task === 'sync_data') {
        // 在这里执行 Eloquent 模型、队列任务等
    }
});

现状与适用边界

Phphone 是独立开发的开源项目,Open Core 加 MIT 许可。官方文档里有一段比较坦率的说明:框架核心遵循 Vanilla、轻量、无依赖的路线,架构上追求 100% 的 Web 兼容(靠的是嵌入式 Chromium/WebKit),但 Web 生态的体量不是一个人能覆盖的,重型库、实验性 JS 特性和激进的客户端路由偶尔会出摩擦。Phphone 把自己定位成开放核心项目,遇到不听话的 JS 库,官方鼓励使用者去看引擎、改代码,再把方案回馈给社区。

iOS 是目前最明显的短板。Phphone 公开说明过,整套 iOS Swift 桥接几乎是在没有 Mac 工作站的情况下写出来的,因此拿到一台 Mac 用于持续集成构建、Xcode 测试和 Apple 生态的维护,被列为最迫切的社区需求,GitHub Sponsors 是当前的资助入口。下一步计划是上线 phphone.org,提供指南与 API 文档、用纯 HTML/PHP/CSS 写的 WhatsApp、电商和 CRM 起始套件,以及一个收录在架应用的展示墙。

适用场景其实不难判断。已经在 PHP 和 Web 前端上攒了多年代码、又不想为了一次移动端发布重写技术栈的团队,是它最直接的受益者:用 Pixi.js 写的 2D 游戏不必为了上架去装 10 GB 的 Unity;Three.js 的 3D 场景能直接吃手机 GPU,跑到 60 FPS 也不受 React Native 桥接的拖累;用 Bootstrap 或 jQuery 拼出来的企业看板,可以当天就交到客户手上。反过来说,需要重量级原生图形能力,或者深度依赖原生 C 扩展生态的项目,走这条路会很别扭。

这套判断背后有一层更实际的理由:1995 年写的 HTML 今天仍然能正常渲染,而三年前的 Flutter 应用常常因为生态变更直接编译不过。界面层押在不会过期的技术上,升级时只需要换掉引擎。

能不能走远,iOS 侧的成熟度是关键变量。

本作品采用《CC 协议》,转载必须注明作者和本文链接