App Inventor 2 上传文件和图片到服务器教程

本文 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

下载 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

下载 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("❌ 上传出错:", 错误信息)
}

安全建议

  1. 服务器端验证文件类型:不要仅依赖客户端传递的文件名,应在服务器端检查文件 MIME 类型和文件内容
  2. 限制文件大小:在服务器端设置合理的文件大小上限
  3. 重命名文件:不要使用客户端原始文件名,使用随机生成的文件名防止路径遍历攻击
  4. 使用 HTTPS:上传过程加密传输,保护数据安全
  5. 目录权限:上传目录不应允许执行脚本(如 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 协议

文档反馈