App Inventor 2 Web客户端(Web)详细用法示例

本文 PHP 后端已配套 Node.js 与 Python 版本,见页面中的对应实现;全部后端源码与运行说明。

« 返回首页

App Inventor 2 如何下载/保存网络图片?

首先,需要使用“Web客户端”组件,需要和网络url进行数据交互的场景就要考虑使用它。

设置好网络图片的url,然后执行Get方法即可,代码如下:

Web保存图片代码块

其中,特别要注意的是保存相应信息属性设置为真,这样才能将目标网络图片保存到本地设备:

如果不指定相应文件名称属性,则保存在本地的图片文件默认是以时间戳作为文件名的一部分的,如下:

Web不指定图片名

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

Web随机文件名

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

Web指定图片名

重新请求一次,会图片文件会原路径进行覆盖。

表单上传文件

服务器要求 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),点「选择图片并上传」选一张图:

  1. 标签先显示「正在上传…」,图像显示选中的图片;
  2. 上传完成后标签显示类似 上传成功:picked_image1234.jpg,235812 字节;标题:我的照片(文件名是图像选择器保存的副本名,数字和大小以实际为准);
  3. 电脑终端打印 收到 picked_image1234.jpg 235812 字节 {'title': '我的照片'},upload-server.py 旁边的 uploads/ 文件夹里出现这张图,能正常打开;
  4. 把网址末尾改成 /wrong 再上传,标签显示 服务器返回 404:{"error": "地址应为 /upload"}——说明 HTTP 错误码也会进「获得文本」,要自己判断状态码;
  5. 关掉电脑上的服务器再上传,标签显示「上传失败:…」——连不上服务器走屏幕的「出现错误时」,不会触发「获得文本」。

退出 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)→ 选择下载的 .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

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

下载 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。指定的编码必须由目标系统支持;读取文件失败时不会发送空请求。

每次请求在调用时保存网址、请求头及响应保存设置。在请求完成前修改组件属性,只影响后续请求。

文档反馈