本文 PHP 后端已配套 Node.js 与 Python 版本,见页面中的对应实现;全部后端源码与运行说明。
App Inventor 2 如何下载/保存网络图片?
首先,需要使用“Web客户端”组件,需要和网络url进行数据交互的场景就要考虑使用它。
设置好网络图片的url,然后执行Get方法即可,代码如下:

其中,特别要注意的是保存相应信息属性设置为真,这样才能将目标网络图片保存到本地设备:
如果不指定相应文件名称属性,则保存在本地的图片文件默认是以时间戳作为文件名的一部分的,如下:

重新请求一次,文件名发生了变化:

指定相应文件名称属性,则图片文件名就是指定的名称:

重新请求一次,会图片文件会原路径进行覆盖。
表单上传文件
服务器要求 multipart/form-data 时,使用 Web 客户端原生方法“执行POST表单文件请求”(PostFileWithForm)。它与原始文件上传 PostFile 是两种不同请求格式。安卓与鸿蒙可用,iOS 暂不支持。
三个参数
| 参数 | 示例 | 含义 |
|---|---|---|
| 路径 | 图像选择器的选中结果 | 与原有文件上传一致,安卓通过统一媒体路径读取,鸿蒙通过资源路径解析器兼容伴侣素材与打包素材 |
| 文件字段名 | file |
接口约定的表单字段名,PHP 使用 $_FILES['file'] |
| 表单字段 | 列表(列表("title", "照片")) |
附加名称/值字段;没有附加字段时用空列表 |
文件名和 MIME 类型从路径推断,未知类型使用 application/octet-stream。组件自动处理表单分隔符,不用自己拼文件字节或设置 Content-Type;Authorization 等鉴权头仍使用“请求头”属性设置。
动手做:手机选图,上传到自己电脑
用电脑当上传服务器,做一个最小闭环:手机选一张图,连同标题一起提交,电脑存下文件并回传文件名、大小和标题,手机显示出来。
准备:一台装有 Python 3.8 以上的电脑;手机能访问电脑的局域网 IP(一般连同一个路由器即可)。
第 1 步:电脑运行接收服务器
把页面末尾的电脑端完整代码保存为 upload-server.py,在它所在目录执行:
python3 upload-server.py
终端显示 上传网址:http://本机局域网IP:8768/upload。
中途验证:电脑浏览器打开
http://127.0.0.1:8768/,看到{"uploads": []}。
第 2 步:确认手机连得到电脑
查电脑的局域网 IP(Windows 运行 ipconfig 看「IPv4 地址」,macOS/Linux 运行 ifconfig 或 ip addr),例如 192.168.1.23。
中途验证:手机浏览器打开
http://192.168.1.23:8768/,同样看到{"uploads": []}。打不开就先解决网络问题(见下方排错表),App 里也一定连不上。
第 3 步:搭界面
| 组件 | 名称 | 设置 |
|---|---|---|
| 文本输入框 | 文本输入框_网址 | 提示:http://电脑局域网IP:8768/upload |
| 文本输入框 | 文本输入框_标题 | 文字:我的照片 |
| 图像选择器 | 图像选择器1 | 文字:选择图片并上传 |
| 图像 | 图像_预览 | 高度 200 像素 |
| 标签 | 标签_状态 | 文字:还没有上传 |
| Web客户端 | Web客户端1 | 超时:15000 |
| 微数据库 | 微数据库1 | — |
第 4 步:拼代码块
when Screen1.Initialize() {
文本输入框_网址.Text = 微数据库1.GetValue("uploadUrl", "")
}
when 图像选择器1.AfterPicking() {
if trim(文本输入框_网址.Text) == "" {
标签_状态.Text = "请先填写上传网址"
} else {
微数据库1.StoreValue("uploadUrl", trim(文本输入框_网址.Text))
图像_预览.Picture = 图像选择器1.Selection
Web客户端1.Url = trim(文本输入框_网址.Text)
// 文件字段名 file 要和服务器一致;附加字段是 [名称, 值] 子列表组成的列表
Web客户端1.PostFileWithForm(图像选择器1.Selection, "file", [["title", 文本输入框_标题.Text]])
标签_状态.Text = "正在上传…"
}
}
when Web客户端1.GotText(URL网址, 响应代码, 响应类型, 响应内容) {
if 响应代码 == 200 {
var 结果 = Web客户端1.JsonTextDecodeWithDictionaries(响应内容)
标签_状态.Text = join("上传成功:", dictget(结果, "filename", ""), ",", dictget(结果, "size", 0), " 字节;标题:", dictget(dictget(结果, "fields", {}), "title", ""))
} else {
标签_状态.Text = join("服务器返回 ", 响应代码, ":", 响应内容)
}
}
when Web客户端1.TimedOut(URL网址) {
标签_状态.Text = "15 秒没有响应:检查网址里的 IP 和端口,手机和电脑是否在同一局域网"
}
when Screen1.ErrorOccurred(组件, 函数名称, 错误编号, 消息) {
标签_状态.Text = join("上传失败:", 消息)
}
第 5 步:运行并核对
在 App 里填 http://192.168.1.23:8768/upload(换成你的 IP),点「选择图片并上传」选一张图:
- 标签先显示「正在上传…」,图像显示选中的图片;
- 上传完成后标签显示类似
上传成功:picked_image1234.jpg,235812 字节;标题:我的照片(文件名是图像选择器保存的副本名,数字和大小以实际为准); - 电脑终端打印
收到 picked_image1234.jpg 235812 字节 {'title': '我的照片'},upload-server.py旁边的uploads/文件夹里出现这张图,能正常打开; - 把网址末尾改成
/wrong再上传,标签显示服务器返回 404:{"error": "地址应为 /upload"}——说明 HTTP 错误码也会进「获得文本」,要自己判断状态码; - 关掉电脑上的服务器再上传,标签显示「上传失败:…」——连不上服务器走屏幕的「出现错误时」,不会触发「获得文本」。
退出 App 再打开,上传网址还在(存进了微数据库)。
常见问题
| 现象 | 原因与处理 |
|---|---|
| 一直「正在上传…」,15 秒后提示没有响应 | 网址里的 IP 不对或手机连不到电脑:先做第 2 步的中途验证;电脑防火墙放行 8768 端口 |
| 「上传失败:Unable to complete the given request…」 | 服务器没开、端口写错,或网址写成了 localhost/127.0.0.1(在手机上指的是手机自己) |
| 服务器返回 400「没有名为 file 的文件字段」 | 第 2 个参数(文件字段名)和服务器要求的不一致,PHP 的 $_FILES['file'] 对应 "file" |
| 自己写的服务器收不到内容、报长度为 0 | 文件按分块传输(Transfer-Encoding: chunked)发送,不带 Content-Length。Apache、Nginx + PHP 会自动处理;自己用框架或底层库写的接口要支持分块请求体 |
| 服务器收到的标题是乱码 | 表单文本按 UTF-8 发送,服务器按 UTF-8 解码即可 |
| 「表单字段」参数报错 | 要传「列表的列表」,每项是 [名称, 值] 两项;没有附加字段时传空列表 |
换用 PostFile 后服务器 $_FILES 为空 |
PostFile 发送的是文件原始字节,不是表单,服务器要用 php://input 读;需要表单就用 PostFileWithForm |
带请求头鉴权
接口需要令牌时,上传前设置请求头。认证等请求头照常发送,Content-Type 由组件按表单自动设置,不用自己填。
when 按钮1.Click() {
Web客户端1.Url = "https://example.com/upload.php"
Web客户端1.RequestHeaders = list(list("Authorization", "Bearer 替换为接口要求的凭据"))
Web客户端1.PostFileWithForm("photo.png", "file", list())
}
上传时依据“保存响应数据”属性触发“获得文本”或“获得文件”;HTTP 4xx/5xx 也会把状态码交给响应事件,应用应检查状态码。文件不可读或参数不是二项名称/值子列表时引发错误,不能当作上传成功。文件字段名、文件名和字段名不能包含换行或 NUL。表单文本采用 UTF-8;文件内容按字节发送,不做 Base64 转换。
完整 .aia 源码
就是上面「动手做」的完整工程。导入方法:项目 → 导入项目(.aia)→ 选择下载的 .aia 文件。
验证范围:最终归档通过正式编辑器首次载入与保存重开(0 错误)、后台导入、安卓安装包编译、鸿蒙 HAP 编译;执行归档里的真实积木,由安卓 Web 组件同一个表单编码器发请求到上面的电脑端代码,8 项断言通过(成功上传后文件逐字节一致、中文标题正确、404 与服务器关闭两种失败提示、重开记住网址)。真机/伴侣尚未实测;iOS 不支持本方法。
电脑端完整代码
只用 Python 标准库,不用安装依赖。默认端口 8768,python3 upload-server.py 9000 可改端口。仅用于局域网测试,正式服务请加鉴权并限制文件类型。
#!/usr/bin/env python3
"""Web 客户端「执行POST表单文件请求」测试服务器:只用 Python 标准库。
运行:python3 upload-server.py (默认端口 8768,可加参数改端口)
手机里的上传网址填:http://电脑的局域网IP:8768/upload
收到的文件存进本目录的 uploads/ 文件夹;浏览器打开 http://127.0.0.1:8768/ 可看已收到的文件。
"""
import json
import os
import sys
from email.parser import BytesParser
from email.policy import default
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
UPLOADS = os.path.join(HERE, 'uploads')
MAX_BYTES = 20 * 1024 * 1024
class Handler(BaseHTTPRequestHandler):
def reply(self, code, data):
body = json.dumps(data, ensure_ascii=False).encode('utf-8')
self.send_response(code)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def read_body(self):
# App Inventor 上传文件用分块传输(Transfer-Encoding: chunked),不带 Content-Length
if 'chunked' in self.headers.get('Transfer-Encoding', '').lower():
raw = b''
while True:
size = int(self.rfile.readline().split(b';')[0].strip() or b'0', 16)
if size == 0:
self.rfile.readline()
break
raw += self.rfile.read(size)
self.rfile.readline()
if len(raw) > MAX_BYTES:
return None
return raw or None
length = int(self.headers.get('Content-Length') or 0)
if length <= 0 or length > MAX_BYTES:
return None
return self.rfile.read(length)
def do_GET(self):
os.makedirs(UPLOADS, exist_ok=True)
files = sorted(os.listdir(UPLOADS))
self.reply(200, {'uploads': files})
def do_POST(self):
# 先把请求体读完再答复,否则手机还在发送时连接被关,只会报「上传失败」看不到原因
raw = self.read_body()
if self.path != '/upload':
return self.reply(404, {'error': '地址应为 /upload'})
ctype = self.headers.get('Content-Type', '')
if not ctype.startswith('multipart/form-data'):
return self.reply(400, {'error': '不是表单上传:' + (ctype or '没有 Content-Type')})
if raw is None:
return self.reply(413, {'error': '请求体为空或超过 20MB'})
message = BytesParser(policy=default).parsebytes(
b'Content-Type: ' + ctype.encode('latin-1') + b'\r\n\r\n' + raw)
fields, upload = {}, None
for part in message.iter_parts():
name = part.get_param('name', header='content-disposition')
filename = part.get_filename()
if filename is not None and name == 'file':
upload = (filename, part.get_payload(decode=True) or b'')
elif name is not None:
# 表单文本按 UTF-8 解码(App Inventor 发送的就是 UTF-8,部件里不带 charset)
fields[name] = (part.get_payload(decode=True) or b'').decode('utf-8', 'replace')
if upload is None:
return self.reply(400, {'error': '没有名为 file 的文件字段', 'fields': fields})
os.makedirs(UPLOADS, exist_ok=True)
safe = os.path.basename(upload[0]) or 'upload'
with open(os.path.join(UPLOADS, safe), 'wb') as f:
f.write(upload[1])
print('收到', safe, len(upload[1]), '字节', fields, flush=True)
self.reply(200, {'filename': safe, 'size': len(upload[1]), 'fields': fields})
if __name__ == '__main__':
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8768
print('上传网址:http://本机局域网IP:%d/upload(Ctrl+C 停止)' % port, flush=True)
ThreadingHTTPServer(('0.0.0.0', port), Handler).serve_forever()
PHP 接收示例
部署在 Apache、Nginx 等 Web 服务器上的 PHP 用 $_FILES 读文件、$_POST 读附加字段。下面是最小接口;正式保存仍需按自己的服务端规则检查文件类型和大小。
<?php
header('Content-Type: application/json; charset=utf-8');
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => '没有收到文件或上传失败'], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode([
'filename' => $_FILES['file']['name'],
'size' => $_FILES['file']['size'],
'title' => $_POST['title'] ?? ''
], JSON_UNESCAPED_UNICODE);
Node.js 与 Python 对应接收示例
以下两个最小接口与上面的 PHP 返回同样的 filename、size、title JSON 字段;文件字段名为 file,附加文本字段为 title,路径为 /upload。启动后将组件网址设为 http://服务器IP:3000/upload。这里只读取文件并回显信息;如需保存文件,使用前面的 Python 保存示例或完整上传服务。
Node.js 对应实现
保存为 multipart-info.cjs,在文件所在目录安装依赖并启动:
npm install express@5 multer@2
node multipart-info.cjs
// npm install express@5 multer@2
const express = require('express');
const multer = require('multer');
const app = express();
const upload = multer({limits: {fileSize: 20 * 1024 * 1024, files: 1}});
app.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({error: '没有收到文件或上传失败'});
res.json({filename: req.file.originalname, size: req.file.size, title: req.body.title || ''});
});
app.use((err, req, res, next) => res.status(400).json({error: '没有收到文件或上传失败'}));
if (require.main === module) app.listen(Number(process.env.PORT || 3000), '0.0.0.0');
module.exports = app;
Python 对应实现
保存为 multipart_info.py,在文件所在目录安装依赖并启动:
python3 -m pip install Flask
python3 multipart_info.py
# python3 -m pip install Flask
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 21 * 1024 * 1024
@app.post('/upload')
def upload():
file = request.files.get('file')
if file is None: return jsonify(error='没有收到文件或上传失败'), 400
data = file.read()
if len(data) > 20 * 1024 * 1024: return jsonify(error='文件过大'), 413
return jsonify(filename=file.filename, size=len(data), title=request.form.get('title', ''))
if __name__ == '__main__':
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 3000)))
使用 PostFile 的旧接口仍通过 php://input 接收原始文件,原有项目可继续使用。对象存储、图床的字段名、签名和鉴权方式各不相同,应按接口文档设置;本方法提供标准表单上传,不代替服务端鉴权。
下载文件与文本编码
下载图片、压缩包或其他文件时,将“保存响应”设为真,在“获得文件”事件中使用返回路径。文件按原始字节保存,不要将“获得文本”的内容当作图片保存。
服务端返回非 UTF-8 文本时,按接口约定设置“响应文本编码”。发送非 UTF-8 文本则使用“执行 POST/PUT/PATCH 文本编码请求”,例如将编码参数设为 GBK 或 UTF-16LE。指定的编码必须由目标系统支持;读取文件失败时不会发送空请求。
每次请求在调用时保存网址、请求头及响应保存设置。在请求完成前修改组件属性,只影响后续请求。
扫码添加客服咨询