- 概述
- 方案对比
- 方案一:PostFile 直接上传
- 方案二:Base64 编码上传
- 服务器端代码(PHP)
- 服务器端代码(Node.js 与 Python)
- 上传进度显示
- 常见问题与错误处理
- 完整示例流程
- 安全建议
- 总结
本文 PHP 后端已配套 Node.js 与 Python 版本,见页面中的对应实现;全部后端源码与运行说明。
概述
在 App Inventor 2 开发中,上传文件或图片到服务器是一个非常常见的需求。无论是拍照上传、选择本地文件上传,还是将 App 中生成的数据发送到远程服务器,App Inventor 2 的 Web客户端(Web)组件都能胜任。
本文将详细介绍两种主流上传方案:PostFile 方案和 Base64 编码方案,并提供完整的服务器端 PHP、Node.js、Python 代码示例。
方案对比
| 对比项 | PostFile 方案 | Base64 编码方案 |
|---|---|---|
| 原理 | 请求体直接发送文件原始二进制数据 | 将文件转为 Base64 字符串,以文本方式发送 |
| 积木块 | Web客户端1.PostFile |
Web客户端1.PostText |
| 传输体积 | 与原文件相同 | 比 POST 大约 33%(Base64 编码膨胀) |
| 服务器端 | 读取原始请求体(PHP 用 php://input) |
需要 base64_decode 解码 |
| 适用场景 | 直接上传文件(拍照、选文件) | 需要在发送前修改文件内容、或与其他文本数据一起传输 |
| 推荐程度 | ⭐⭐⭐ 首选方案 | ⭐⭐ 备选方案 |
推荐:大多数场景下使用 PostFile 方案,更简单高效。
方案一:PostFile 直接上传
原理
Web客户端 组件的 PostFile 方法将指定路径的文件以 multipart/form-data 格式 POST 到服务器 URL。服务器端使用标准的文件上传处理方式即可接收。
组件准备
在 App Inventor 2 设计视图中,添加以下组件:
| 组件 | 名称 | 用途 |
|---|---|---|
| Button | 按钮_上传 |
点击上传按钮 |
| Camera | 照相机1 |
拍照组件 |
| ImagePicker | ImagePicker1 |
从相册选择图片 |
| Web | Web客户端1 |
网络请求组件 |
| Label | 标签_状态 |
显示上传状态 |
| TextBox | TextBox_ServerUrl |
输入服务器地址(测试用) |
积木块代码
1. 拍照后上传
when Button_拍照上传.Click() {
照相机1.TakePicture()
}
when 照相机1.AfterPicture(图片路径) {
global global_CurrentFilePath = 图片路径
标签_状态.Text = "正在上传..."
Web客户端1.Url = "http://你的服务器地址/upload.php"
Web客户端1.PostFile(图片路径)
}
2. 从相册选择图片后上传
when Button_选择上传.Click() {
ImagePicker1.Open()
}
when ImagePicker1.AfterPicking(选择结果) {
if 选择结果 != "" {
global global_CurrentFilePath = 选择结果
标签_状态.Text = "正在上传..."
Web客户端1.Url = "http://你的服务器地址/upload.php"
Web客户端1.PostFile(选择结果)
}
}
3. 处理上传结果
when Web客户端1.GotText(响应代码, 响应类型, 响应内容) {
if 响应代码 = 200 {
标签_状态.Text = join("上传成功!服务器返回:", 响应内容)
} else {
标签_状态.Text = join("上传失败,错误码:", 响应代码)
}
}
when Web客户端1.ErrorOccurred(错误信息) {
标签_状态.Text = join("上传出错:", 错误信息)
}
方案二:Base64 编码上传
原理
将文件内容通过 File 组件读取为文本,再用 Web客户端 组件的 PostText 方法发送 Base64 编码的字符串到服务器。服务器端解码后保存为文件。
组件准备
在方案一的基础上,额外添加:
| 组件 | 名称 | 用途 |
|---|---|---|
| File | 文件管理器1 |
读取文件内容 |
积木块代码
1. 读取文件并 Base64 编码上传
when 按钮_上传.Click() {
标签_状态.Text = "正在读取文件..."
文件管理器1.ReadFile(global_CurrentFilePath)
}
when 文件管理器1.AfterFileRead(文本内容) {
标签_状态.Text = "正在上传..."
Web客户端1.Url = "http://你的服务器地址/upload_base64.php"
global global_Base64Data = 文本内容
Web客户端1.RequestHeaders = list(list("Content-Type", "application/x-www-form-urlencoded"))
Web客户端1.PostText(join("data=", Web客户端1.UriEncode(global_Base64Data)))
}
注意:
文件管理器1.转换为文件路径方法用于将外部路径(如相册返回的路径)转换为 App 可读取的路径。在某些设备上,可能需要使用content://路径的处理方式。
2. 处理上传结果(同方案一)
when Web客户端1.GotText(响应代码, 响应类型, 响应内容) {
if 响应代码 = 200 {
标签_状态.Text = "上传成功!"
} else {
标签_状态.Text = "上传失败"
}
}
when Web客户端1.ErrorOccurred(错误信息) {
标签_状态.Text = join("上传出错:", 错误信息)
}
服务器端代码(PHP)
方案一服务器端:upload.php(接收 PostFile)
<?php
/**
* App Inventor 2 PostFile 上传接收脚本
* 接收原始字节;也兼容 PostFileWithForm 的 multipart 上传
*/
// 设置响应头
header('Content-Type: text/plain; charset=utf-8');
// 上传文件保存目录(确保目录存在且有写权限)
$uploadDir = 'uploads/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// PostFile 的请求体是原始字节,不会出现在 $_FILES 中。
if (stripos($_SERVER['CONTENT_TYPE'] ?? '', 'multipart/form-data') !== 0) {
$data = file_get_contents('php://input');
if ($data === false || strlen($data) === 0) {
http_response_code(400); echo "错误:未接收到文件"; exit;
}
if (strlen($data) > 20 * 1024 * 1024) {
http_response_code(413); echo "错误:文件过大"; exit;
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($data);
$types = ['image/jpeg'=>'jpg', 'image/png'=>'png', 'image/gif'=>'gif',
'image/webp'=>'webp', 'application/pdf'=>'pdf'];
$name = date('Ymd_His') . '_' . uniqid() . '.' . ($types[$mime] ?? 'bin');
if (file_put_contents($uploadDir . $name, $data) !== false) echo "上传成功|" . $name;
else { http_response_code(500); echo "错误:文件保存失败"; }
exit;
}
// 检查是否有表单文件上传
if (!isset($_FILES) || empty($_FILES)) {
// multipart 请求必须包含文件字段
http_response_code(400);
echo "错误:未接收到文件";
exit;
}
// 获取上传的文件信息
// PostFileWithForm 的字段名由调用方指定,这里取第一个文件
$fileKey = array_keys($_FILES)[0];
$file = $_FILES[$fileKey];
if ($file['error'] !== UPLOAD_ERR_OK) {
$errorMessages = [
UPLOAD_ERR_INI_SIZE => '文件超过 php.ini 中 upload_max_filesize 的限制',
UPLOAD_ERR_FORM_SIZE => '文件超过表单中 MAX_FILE_SIZE 的限制',
UPLOAD_ERR_PARTIAL => '文件只有部分被上传',
UPLOAD_ERR_NO_FILE => '没有文件被上传',
UPLOAD_ERR_NO_TMP_DIR => '找不到临时目录',
UPLOAD_ERR_CANT_WRITE => '写入磁盘失败',
];
$msg = isset($errorMessages[$file['error']])
? $errorMessages[$file['error']]
: '未知上传错误';
http_response_code(400);
echo "错误:" . $msg;
exit;
}
// 生成安全的文件名
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
if (empty($ext)) {
// 如果没有扩展名,根据 MIME 类型推断
$mimeToExt = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
$ext = isset($mimeToExt[$file['type']]) ? $mimeToExt[$file['type']] : 'bin';
}
$newName = date('Ymd_His') . '_' . uniqid() . '.' . $ext;
$targetPath = $uploadDir . $newName;
// 移动文件到目标目录
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
echo "上传成功|" . $newName;
} else {
http_response_code(500);
echo "错误:文件保存失败";
}
?>
方案二服务器端:upload_base64.php(接收 Base64 数据)
<?php
/**
* App Inventor 2 Base64 上传接收脚本
* 接收 Base64 编码的文件数据
*/
header('Content-Type: text/plain; charset=utf-8');
$uploadDir = 'uploads/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// 获取 POST 数据
$rawInput = file_get_contents('php://input');
// 方式1:从 application/x-www-form-urlencoded 获取
parse_str($rawInput, $postData);
$base64Data = isset($postData['data']) ? $postData['data'] : '';
if (empty($base64Data)) {
// 方式2:直接获取原始文本
$base64Data = $rawInput;
}
if (stripos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') === 0) {
$json = json_decode($rawInput, true);
$base64Data = $json['data'] ?? $json['image_base64'] ?? '';
}
// 去除可能的 data URI 前缀
if (preg_match('/^data:[^;]+;base64,(.+)$/s', $base64Data, $matches)) {
$base64Data = $matches[1];
}
// Base64 解码
$fileData = base64_decode($base64Data, true);
if ($fileData === false) {
http_response_code(400);
echo "错误:Base64 解码失败";
exit;
}
// 通过文件头判断文件类型
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->buffer($fileData);
$mimeToExt = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
'text/plain' => 'txt',
];
$ext = isset($mimeToExt[$mime]) ? $mimeToExt[$mime] : 'bin';
$newName = date('Ymd_His') . '_' . uniqid() . '.' . $ext;
$targetPath = $uploadDir . $newName;
// 写入文件
if (file_put_contents($targetPath, $fileData) !== false) {
echo "上传成功|" . $newName;
} else {
http_response_code(500);
echo "错误:文件保存失败";
}
?>
服务器端代码(Node.js 与 Python)
下面每个文件都是完整服务,同时对应上面的两段 PHP:
| PHP 入口 | Node.js / Python 入口 | 请求体 | 成功响应 |
|---|---|---|---|
upload.php |
/upload.php(也支持 /upload) |
PostFile 原始字节,或 PostFileWithForm 的 multipart 文件 | 上传成功|文件名 |
upload_base64.php |
/upload_base64.php(也支持 /upload_base64) |
原始 Base64 文本、表单字段 data,或 JSON 字段 data / image_base64 |
上传成功|文件名 |
启动后,将 Web 客户端网址改为 http://服务器IP:3000/upload.php 或 http://服务器IP:3000/upload_base64.php。程序在脚本旁创建 uploads/,也可设置 UPLOAD_DIR;单个文件上限 20 MiB,Base64 请求上限 28 MiB。无已识别文件头的内容保存为 .bin,文件内容保持原样。
Base64 包含 +、/、=。用表单发送时,先用 UriEncode 编码字段值;最简单的是设置 Content-Type: text/plain,再 PostText(Base64文本)。发送 PNG、JPEG 等二进制文件请用 PostFile,不要把图片作为普通文本读取。
Node.js 对应实现
保存为 upload.cjs,在文件所在目录安装依赖并启动:
npm install express@5 multer@2
node upload.cjs
// npm install express@5 multer@2
const express = require('express');
const multer = require('multer');
const fs = require('node:fs');
const path = require('node:path');
const {randomUUID} = require('node:crypto');
const app = express();
const dir = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, 'uploads'));
fs.mkdirSync(dir, {recursive: true});
const form = multer({limits: {fileSize: 20 * 1024 * 1024, files: 1}});
function extension(data) {
if (data.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) return 'jpg';
if (data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'png';
if (/^GIF8[79]a/.test(data.subarray(0, 6).toString('ascii'))) return 'gif';
if (data.subarray(0, 4).toString() === 'RIFF' && data.subarray(8, 12).toString() === 'WEBP') return 'webp';
if (data.subarray(0, 5).toString() === '%PDF-') return 'pdf';
return 'bin';
}
function save(data, res) {
if (!data.length) return res.status(400).type('text').send('错误:未接收到文件');
const name = `${Date.now()}_${randomUUID()}.${extension(data)}`;
fs.writeFileSync(path.join(dir, name), data, {flag: 'wx'});
res.type('text').send('上传成功|' + name);
}
// PostFile 是原始字节;PostFileWithForm 才是 multipart。
app.post(['/upload.php', '/upload'], (req, res, next) => {
if (req.is('multipart/form-data')) {
form.any()(req, res, err => {
if (err) return next(err);
if (!req.files.length) return res.status(400).type('text').send('错误:未接收到文件');
try { save(req.files[0].buffer, res); } catch (error) { next(error); }
});
} else {
express.raw({type: () => true, limit: '20mb'})(req, res, err => {
if (err) return next(err);
try { save(Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0), res); } catch (error) { next(error); }
});
}
});
app.post(['/upload_base64.php', '/upload_base64'], express.text({type: () => true, limit: '28mb'}), (req, res) => {
const raw = typeof req.body === 'string' ? req.body : '';
let text = req.is('application/x-www-form-urlencoded') ? (new URLSearchParams(raw).get('data') || '') : raw;
if (req.is('application/json')) {
try { const data = JSON.parse(raw); text = data.data ?? data.image_base64 ?? ''; }
catch (error) { return res.status(400).type('text').send('错误:JSON 格式无效'); }
}
if (typeof text !== 'string') return res.status(400).type('text').send('错误:Base64 数据必须是文本');
text = text.replace(/^data:[^;]+;base64,/, '').replace(/\s/g, '');
if (!text || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) {
return res.status(400).type('text').send('错误:Base64 解码失败');
}
const bytes = Buffer.from(text, 'base64');
if (bytes.length > 20 * 1024 * 1024) return res.status(413).type('text').send('错误:文件过大');
save(bytes, res);
});
app.use((err, req, res, next) => res.status(err.status || (err instanceof multer.MulterError ? 400 : 500)).type('text').send('错误:上传或保存失败'));
if (require.main === module) app.listen(Number(process.env.PORT || 3000), '0.0.0.0');
module.exports = app;
Python 对应实现
保存为 upload.py,在文件所在目录安装依赖并启动:
python3 -m pip install Flask
python3 upload.py
# python3 -m pip install Flask
import base64
import binascii
import os
import re
import uuid
from pathlib import Path
from flask import Flask, request, Response
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 28 * 1024 * 1024
DIRECTORY = Path(os.environ.get('UPLOAD_DIR', Path(__file__).parent / 'uploads'))
DIRECTORY.mkdir(parents=True, exist_ok=True)
def reply(text, status=200):
return Response(text, status=status, mimetype='text/plain')
def extension(data):
if data.startswith(b'\xff\xd8\xff'): return 'jpg'
if data.startswith(b'\x89PNG\r\n\x1a\n'): return 'png'
if data[:6] in (b'GIF87a', b'GIF89a'): return 'gif'
if data[:4] == b'RIFF' and data[8:12] == b'WEBP': return 'webp'
if data.startswith(b'%PDF-'): return 'pdf'
return 'bin'
def save(data):
if not data: return reply('错误:未接收到文件', 400)
if len(data) > 20 * 1024 * 1024: return reply('错误:文件过大', 413)
name = f'{uuid.uuid4().hex}.{extension(data)}'
try:
with (DIRECTORY / name).open('xb') as file:
file.write(data)
except OSError:
return reply('错误:文件保存失败', 500)
return reply('上传成功|' + name)
@app.post('/upload.php')
@app.post('/upload')
def upload():
if request.mimetype == 'multipart/form-data':
file = next(iter(request.files.values()), None)
if file is None: return reply('错误:未接收到文件', 400)
return save(file.read())
return save(request.get_data())
@app.post('/upload_base64.php')
@app.post('/upload_base64')
def upload_base64():
text = request.form.get('data', '') if request.mimetype == 'application/x-www-form-urlencoded' else request.get_data(as_text=True)
if request.is_json:
data = request.get_json(silent=True)
if not isinstance(data, dict): return reply('错误:JSON 格式无效', 400)
text = data.get('data', data.get('image_base64', ''))
if not isinstance(text, str): return reply('错误:Base64 数据必须是文本', 400)
text = re.sub(r'^data:[^;]+;base64,', '', text)
text = re.sub(r'\s', '', text)
try:
data = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError):
return reply('错误:Base64 解码失败', 400)
return save(data)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 3000)))
上传进度显示
App Inventor 2 的 Web客户端 组件不提供上传进度回调。但你可以通过以下方式改善用户体验:
方式一:显示加载对话框
when 按钮_上传.Click() {
Web客户端1.PostFile(global_CurrentFilePath)
}
when Web客户端1.GotText(响应代码, 响应类型, 响应内容) {
if 响应代码 = 200 {
标签_状态.Text = "上传成功!"
} else {
标签_状态.Text = "上传失败"
}
}
when Web客户端1.ErrorOccurred(错误信息) {
标签_状态.Text = join("上传出错:", 错误信息)
}
方式二:使用 Clock 组件模拟进度
when 按钮_上传.Click() {
global global_Progress = 0
标签_状态.Text = "上传中 0%"
计时器1.TimerEnabled = true
Web客户端1.PostFile(global_CurrentFilePath)
}
when 计时器1.Timer() {
if global_Progress < 90 {
global_Progress = global_Progress + 10
标签_状态.Text = join("上传中 ", global_Progress, "%")
}
}
when Web客户端1.GotText(响应代码, 响应类型, 响应内容) {
计时器1.TimerEnabled = false
标签_状态.Text = "上传完成 100%"
}
常见问题与错误处理
1. 网络连接失败
现象:触发 Web客户端1.发生错误 事件,错误信息包含 connect 或 timeout。
解决方案:
when Web客户端1.ErrorOccurred(错误信息) {
if textContains(错误信息, "connect") or textContains(错误信息, "timeout") {
标签_状态.Text = "网络连接失败,请检查网络"
} else {
标签_状态.Text = join("上传出错:", 错误信息)
}
}
排查清单:
- 检查手机是否联网
- 检查服务器 URL 是否正确(注意使用
http://或https://) - 如果是本地测试,确保手机和电脑在同一局域网,且使用电脑的局域网 IP(如
http://192.168.1.100/upload.php) - 不要使用
localhost或127.0.0.1,这指向手机自身
2. 服务器返回 413 错误
现象:响应代码为 413(Request Entity Too Large)。
原因:上传文件超过了服务器的限制。
解决方法:修改 PHP 配置 php.ini:
upload_max_filesize = 20M
post_max_size = 25M
max_execution_time = 60
修改后重启 Web 服务器。
3. 文件路径问题
现象:PostFile 调用后,服务器未收到文件或收到空文件。
原因:App Inventor 2 中不同来源的文件路径格式不同:
| 来源 | 路径示例 |
|---|---|
| Camera 拍照 | /storage/emulated/0/PIC_123.jpg |
| ImagePicker 选择 | content://com.android.providers.media.documents/document/image%3A123 |
| App 内部文件 | /data/data/pkg.name/files/myfile.txt |
| ASD 目录 | /storage/emulated/0/Android/data/pkg.name/files/myfile.txt |
提示:对于 ImagePicker 返回的
content://路径,PostFile方法通常可以直接处理。如果遇到问题,可以先通过File组件将文件复制到 ASD 目录再上传。
4. 权限问题
确保 App 拥有必要的权限。在 App Inventor 2 中:
- Android 11+(API 30+):App 默认使用 ASD(App Specific Directory),无需额外存储权限
- 读取相册图片:ImagePicker 组件会自动处理权限请求
- 网络权限:App Inventor 2 编译的 App 默认包含网络权限
5. HTTPS 与 HTTP
从 Android 9(API 28)开始,默认不允许明文 HTTP 请求。
- 推荐:使用 HTTPS 服务器
- 测试用:可以在服务器端配置允许 HTTP,或在 App Inventor 2 的 Screen 组件属性中确认目标 API Level
完整示例流程
以下是一个”拍照 + 上传”的完整流程:
步骤一:拍照
when Button_拍照上传.Click() {
照相机1.TakePicture()
}
步骤二:拍照完成后上传
when 照相机1.AfterPicture(图片路径) {
图像1.Picture = 图片路径
标签_状态.Text = "正在上传..."
Web客户端1.Url = "https://你的服务器.com/upload.php"
Web客户端1.PostFile(图片路径)
}
步骤三:处理上传结果
when Web客户端1.GotText(响应代码, 响应类型, 响应内容) {
if 响应代码 = 200 {
标签_状态.Text = "✅ 上传成功!"
} else {
标签_状态.Text = join("❌ 上传失败:", 响应代码)
}
}
when Web客户端1.ErrorOccurred(错误信息) {
标签_状态.Text = join("❌ 上传出错:", 错误信息)
}
安全建议
- 服务器端验证文件类型:不要仅依赖客户端传递的文件名,应在服务器端检查文件 MIME 类型和文件内容
- 限制文件大小:在服务器端设置合理的文件大小上限
- 重命名文件:不要使用客户端原始文件名,使用随机生成的文件名防止路径遍历攻击
- 使用 HTTPS:上传过程加密传输,保护数据安全
- 目录权限:上传目录不应允许执行脚本(如 PHP),可配置
.htaccess:
# uploads/.htaccess - 禁止执行 PHP
<FilesMatch "\.php$">
Order Deny,Allow
Deny from all
</FilesMatch>
总结
| 要点 | 说明 |
|---|---|
| 推荐方案 | 使用 Web客户端1.PostFile 直接上传,简单高效 |
| 服务器端 | PHP 使用 $_FILES 接收文件,标准且简单 |
| 错误处理 | 始终在 Web客户端1.发生错误 和 Web客户端1.收到返回内容 中检查结果 |
| 进度显示 | 使用 Notifier 组件显示加载对话框 |
| 安全 | 服务器端验证文件类型、限制大小、重命名文件 |
掌握文件上传功能后,你可以实现用户头像上传、图片分享、文档提交等多种实用功能。
© App Inventor 2 中文网 ©2025 · 本文内容遵循 CC BY-SA 4.0 协议
扫码添加客服咨询