不用学 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.yml 或 phphone_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 的会议"
  }
}

订单进度这类需要原地更新的,给一个固定的 tag 或 id,同一 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.png、splash.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 run 或 phphone 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 这边,$_POST 和 php://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 协议》,转载必须注明作者和本文链接