媒体 API
Image、AsyncImage、PhotoPicker、CameraPicker、VideoPlayer、WebView 和 MapView。
本页覆盖图片、相册、相机、文件导入、地图、网页、视频和图标标题。媒体视图仍然是普通 View,可以继续使用 .frame(...)、.padding(...)、.background(...)、.clipped() 等修饰符。
#什么时候用
| 目标 | 首选 API | 说明 |
|---|---|---|
| SF Symbol 或本地图片 | Image | 图标、资产目录图片、可缩放图片。 |
| 网络图片 | AsyncImage | 支持占位视图和失败视图。 |
| 图标 + 标题 | Label | 按钮、列表行、Tab、菜单项里的标准图标标题。 |
| 从相册选择 | PhotoPicker | 选择图片或视频,回调返回路径列表。 |
| 拍照或录像 | CameraPicker | 使用系统相机,回调返回路径字符串。 |
| 从文件导入 | FileImporter | 打开系统文件选择器,回调返回导入文件路径列表。 |
| 视频播放 | VideoPlayer | 内联或全屏视频播放,支持 AirPlay、PiP。 |
| 播放器控制 | PlayerController | 复用同一个原生播放器实例,控制播放、暂停、进度、倍速、音量和事件回调。 |
| 网页内容 | WebView | 加载 URL 或内联 HTML。 |
| 地图展示 | MapView | 显示 Apple Maps、中心点、缩放跨度和标记。 |
#最小正确示例
import appui
state = appui.State(status="Ready", picked_count=0, imported_count=0)
def image_loaded():
state.status = "Image loaded"
def image_failed():
state.status = "Image failed"
def receive_photos(paths):
state.picked_count = len(paths or [])
state.status = f"Picked {state.picked_count} item(s)"
def receive_files(paths):
state.imported_count = len(paths or [])
state.status = f"Imported {state.imported_count} file(s)"
def body():
return appui.NavigationStack(
appui.List([
appui.Section("Image", [
appui.AsyncImage(
url="https://www.w3.org/Icons/w3c_home",
placeholder=appui.ProgressView(label="Loading"),
error_view=appui.Label("Failed", system_image="wifi.slash"),
content_mode="fit",
on_success=image_loaded,
on_failure=image_failed,
)
.frame(height=90)
.background("secondarySystemBackground", corner_radius=8),
]),
appui.Section("Picker", [
appui.PhotoPicker(
selection_limit=2,
filter="images",
on_picked=receive_photos,
label=appui.Label("Choose Photos", system_image="photo.on.rectangle"),
),
appui.Text(state.status).font("footnote").foreground_color("secondaryLabel"),
]),
appui.Section("Files", [
appui.FileImporter(
allowed_types=["text", "pdf", "csv"],
allows_multiple=True,
on_picked=receive_files,
label=appui.Label("Import Files", system_image="doc.badge.plus"),
),
]),
]).navigation_title("Media")
)
appui.run(body, state=state)
#图片和标签
| API | 签名 | 分类 |
|---|---|---|
Image | Image(name: Optional[str] = None, system_name: Optional[str] = None, systemName: Optional[str] = None) | media |
Label | Label(title: str = '', system_image: Optional[str] = None, image: Optional[str] = None, systemImage: Optional[str] = None) | text |
AsyncImage | AsyncImage(url: str = '', placeholder: Optional[View] = None, error_view: Optional[View] = None, content_mode: str = 'fit', on_success: Optional[Callable] = None, on_failure: Optional[Callable] = None) | media |
#Image 方法
| 方法 | 说明 |
|---|---|
.resizable() | 允许图片按 frame 缩放。 |
.aspect_ratio(ratio=None, content_mode='fit', **kwargs) | 设置宽高比和填充模式;content_mode 为 "fit" 或 "fill"。 |
.symbol_rendering_mode(mode) | SF Symbol 渲染:"hierarchical"、"palette"、"multicolor"、"monochrome"。 |
.image_scale(scale) | SF Symbol 尺寸:"small"、"medium"、"large"。 |
import appui
def body():
symbol = (
appui.Image(system_name="heart.fill")
.symbol_rendering_mode("multicolor")
.image_scale("large")
.foreground_color("systemPink")
)
photo = (
appui.Image(name="example")
.resizable()
.aspect_ratio(content_mode="fit")
.frame(height=90)
.background("secondarySystemBackground", corner_radius=8)
)
return appui.NavigationStack(
appui.VStack([symbol, photo], spacing=16)
.padding()
.navigation_title("Images")
)
appui.run(body)
#相册、相机和文件导入
| API | 签名 | 分类 |
|---|---|---|
PhotoPicker | PhotoPicker(selection_limit: int = 1, filter: str = 'images', on_picked: Optional[Callable] = None, label: Optional[View] = None, selectionLimit: Optional[int] = None, onPicked: Optional[Callable] = None, *, on_error: Optional[Callable[[Dict[str, Any]], Any]] = None, onError: Optional[Callable[[Dict[str, Any]], Any]] = None, **kwargs: Any) | media |
CameraPicker | CameraPicker(source: str = 'camera', media_type: str = 'photo', on_captured: Optional[Callable] = None, label: Optional[View] = None, mediaType: Optional[str] = None, onCaptured: Optional[Callable] = None, *, on_cancel: Optional[Callable[[], Any]] = None, on_error: Optional[Callable[[Dict[str, Any]], Any]] = None, quality: float = 0.9, allows_editing: bool = False, video_quality: str = 'high', maximum_duration: Optional[float] = None, **kwargs: Any) | media |
FileImporter | FileImporter(allowed_types: Optional[Union[str, Sequence[str]]] = None, allows_multiple: bool = False, copy: bool = True, on_picked: Optional[Callable] = None, label: Optional[View] = None, allowedTypes: Optional[Union[str, Sequence[str]]] = None, allowsMultiple: Optional[bool] = None, onPicked: Optional[Callable] = None, *, on_error: Optional[Callable[[Dict[str, Any]], Any]] = None, **kwargs: Any) | media |
回调契约:PhotoPicker.on_picked(paths) 与 FileImporter.on_picked(paths) 接收路径字符串列表;CameraPicker.on_captured(path) 接收单个路径字符串。on_error(error) 接收包含 domain、code、message 的字典;相机取消使用独立的 on_cancel(),不会伪装成成功或错误。
选择器返回的临时文件属于当前 MiniApp 运行会话:普通 UI 重建、Tab 切换和热更新 generation 不会提前删除;MiniApp 真正停止后自动清理。要跨运行长期保存,请在回调中复制到自己的项目或存储目录。
| API | 生产级行为 |
|---|---|
PhotoPicker | 使用文件式媒体传输,不把整张照片或视频一次性读入内存;新选择会取消旧传输,取消或失败会清理半成品。 |
CameraPicker | quality 控制 JPEG 质量;allows_editing 使用系统裁剪;录像可设置 video_quality 与 maximum_duration。 |
FileImporter(copy=True) | 在安全访问范围内复制到应用临时目录后回调,最适合普通 MiniApp。 |
FileImporter(copy=False) | 返回提供方 URL,并在视图存活期间保持安全访问;不要把该路径当作永久授权。 |
import appui
state = appui.State(last_path="")
def receive_capture(path):
state.last_path = path or "No file"
def body():
return appui.NavigationStack(
appui.Form([
appui.Section("Camera", [
appui.CameraPicker(
source="front",
media_type="photo",
on_captured=receive_capture,
label=appui.Label("Take Photo", system_image="camera"),
),
appui.Text(state.last_path or "No capture yet")
.font("footnote")
.foreground_color("secondaryLabel"),
])
]).navigation_title("Camera")
)
appui.run(body, state=state)
import appui
state = appui.State(files=[])
def receive_files(paths):
state.files = paths or []
def body():
rows = [
appui.Text(path).font("footnote").line_limit(1)
for path in state.files
]
return appui.NavigationStack(
appui.Form([
appui.Section("Import", [
appui.FileImporter(
allowed_types=["text", "pdf", "csv"],
allows_multiple=True,
on_picked=receive_files,
label=appui.Label("Import Files", system_image="folder"),
),
]),
appui.Section("Files", rows or [
appui.ContentUnavailableView(
"No file",
system_image="doc",
description="Import a document first",
)
]),
]).navigation_title("Files")
)
appui.run(body, state=state)
#视频、网页和地图
#VideoPlayer {#video-player}
#PlayerController {#player-controller}
#WebView {#webview}
#视频 API 选择规则
| 场景 | 推荐写法 | 说明 |
|---|---|---|
| 只需要在页面里显示并播放一个视频 | VideoPlayer(url=...) | 最简单,适合普通预览、详情页视频。 |
| Mini App 需要播放/暂停/seek/倍速/进度保存/PiP 状态 | PlayerController + VideoPlayer(player=player) | AppUI 视频类应用的主入口。 |
| 不写 AppUI 页面,只写脚本播放音视频 | import avplayer | 脚本级媒体能力;AppUI 新页面不要优先用它控制内嵌播放器。 |
| API | 签名 | 分类 |
|---|---|---|
VideoPlayer | VideoPlayer(url: str = '', autoplay: bool = False, loop: bool = False, show_controls: bool = True, presentation: str = 'inline', allows_fullscreen: bool = True, allows_pip: bool = True, allows_airplay: bool = True, video_gravity: str = 'resizeAspect', enters_fullscreen_when_playback_begins: bool = False, exits_fullscreen_when_playback_ends: bool = True, showControls: Optional[bool] = None, allowsFullscreen: Optional[bool] = None, allowsPiP: Optional[bool] = None, allowsPictureInPicture: Optional[bool] = None, allowsAirPlay: Optional[bool] = None, videoGravity: Optional[str] = None, entersFullscreenWhenPlaybackBegins: Optional[bool] = None, exitsFullscreenWhenPlaybackEnds: Optional[bool] = None, allows_picture_in_picture: Optional[bool] = None, player: Optional[PlayerController] = None, player_id: Optional[str] = None, pause_on_disappear: Optional[bool] = None) | media |
PlayerController | PlayerController(id: str = 'main', url: str = '', autoplay: bool = False, loop: bool = False, rate: float = 1.0, volume: float = 1.0, allows_pip: bool = True, allows_airplay: bool = True, video_gravity: str = 'resizeAspect', pause_on_disappear: bool = True) | 公开类型 |
WebView | WebView(url: Optional[str] = None, html: Optional[str] = None, *, base_url: Optional[str] = None, allows_javascript: bool = True, allows_navigation_gestures: bool = True, allows_link_preview: bool = True, custom_user_agent: Optional[str] = None, allowed_hosts: Optional[Sequence[str]] = None, data_store: str = 'default', navigation_state: Any = None, on_navigation_change: Optional[Callable[[Dict[str, Any]], Any]] = None, on_message: Optional[Callable[[Dict[str, Any]], Any]] = None, on_error: Optional[Callable[[Dict[str, Any]], Any]] = None, command: Any = None, on_command_result: Optional[Callable[[Dict[str, Any]], Any]] = None, allows_downloads: bool = False, on_download: Optional[Callable[[str], Any]] = None, on_download_error: Optional[Callable[[Dict[str, Any]], Any]] = None, progress_minimum_interval: float = 0.1) | media |
MapView | MapView(latitude: float = 37.7749, longitude: float = -122.4194, span: float = 0.05, markers: Optional[Sequence[Dict[str, Any]]] = None, map_style: str = 'automatic', mapStyle: Optional[str] = None, *, region: Any = None, on_region_change: Optional[Callable[[Dict[str, float]], Any]] = None, selection: Any = None, on_selection_change: Optional[Callable[[Optional[str]], Any]] = None, on_marker_tap: Optional[Callable[[Dict[str, Any]], Any]] = None, overlays: Optional[Sequence[Dict[str, Any]]] = None, shows_user_location: bool = False, controls: Optional[Sequence[str]] = None, interaction_modes: Any = 'all', camera_frequency: str = 'on_end', shows_traffic: bool = False, elevation: str = 'automatic') | media |
ShareLink | ShareLink(item: str = '', subject: Optional[str] = None, message: Optional[str] = None) | control |
#WebView V2
WebView 是生命周期归属当前节点的可编程浏览器,而不是一次性网页截图。navigation_state 可传 Binding,收到:phase、url、title、progress、can_go_back、can_go_forward。网页可通过 window.webkit.messageHandlers.appui.postMessage(value) 向 on_message 发送 JSON 兼容值。
命令使用带稳定 id 的字典;同一个 id 只执行一次,避免页面重建重复刷新、重复执行脚本或重复导航。
state = appui.State(web={}, command=None, result={})
def reload_page():
state.command = {"id": "reload-1", "op": "reload"}
def run_script():
state.command = {
"id": "read-title-1",
"op": "evaluate_javascript",
"script": "document.title",
}
def current_command():
return state.command
def receive_result(value):
state.result = value
web = appui.WebView(
url="https://docs.example.com",
allowed_hosts=["docs.example.com", "*.cdn.example.com"],
data_store="non_persistent",
navigation_state=state.bind.web,
command=current_command,
on_command_result=receive_result,
)
可用 op:reload、stop、go_back、go_forward、evaluate_javascript、load_url、load_html、get_cookies、set_cookie、delete_cookie、clear_website_data。Cookie 命令和网站数据清理需要 network;声明式安全 profile 只会读取、修改或清除 allowlist 内域名的数据,不会暴露或删除其他 MiniApp 的站点数据。clear_website_data 的 data_types 可选 cookies、disk_cache、memory_cache、local_storage、session_storage、indexed_db、service_workers;省略表示全部。
下载必须同时传 allows_downloads=True 与 on_download(path),并声明 file_system。下载由 WebKit 原生下载任务执行;完成后的临时文件跨 UI 重建可用,并在所属 MiniApp 运行会话结束时清理。失败或策略拒绝由 on_download_error(error) 接收。
系统 JavaScript alert / confirm / prompt 会使用原生对话框。WebView 的原生权限 lease 固定归属创建它的 AppUI 会话;普通 generation 切换会继承同一 lease,但 MiniApp 停止、宿主运行取消或会话撤销后,页面会立即停止并清空,下载会取消,同时移除 message handler、content rule、观察者和 delegate。旧页面不能从下一次运行重新取得权限。
allowed_hosts 只能缩小 miniapp.json 中 allowedNetworkHosts 的范围,不能扩大;两者都限制为最多 128 条、UTF-8 总计最多 32768 字节。运行时会重新规范化页面声明,并与当前会话不可变的权限清单求交;超限、非法或越权规则会安全拒绝。权限快速切换时每个 WebView 只保留一个正在编译和一个最新待编译策略,全局 WebKit 编译队列同样有界。声明式安全 profile 的普通远程 origin 只允许 HTTPS/WSS,FTP、FTPS 和自定义分层网络 scheme 也会在 WebKit 子资源层阻断。当前会话通过 http_server.start() 创建的精确 universal token path,以及 url_mode="browser" 创建的精确 localhost origin,是受生命周期约束的本地例外,无需加入 host allowlist。停止 server 或结束会话后两种授权立即撤销。
本地内容可以写成绝对路径或 file:// URL。声明式 MiniApp 访问自身工作区需要 file_system;当前运行由系统分享或外部启动交付的 LaunchItem.path 及其 host-issued 临时只读根是例外,无需 file_system,但不能写入、跨会话复用或访问其他路径。Python 层会先解析为当前 MiniApp 可读根内、真实存在且非符号链接逃逸的规范 URL;WebView(url=本地文件) 由原生 loadFileURL(..., allowingReadAccessTo=授权根) 加载。内联页面也支持 WebView(html=..., base_url=本地文件、目录或授权根本身),供同一授权根内的 CSS、JS、图片使用。只有创建时声明了本地 url 或本地 base_url 的节点,才可用 load_url 在该原生范围内切换本地文件;普通远程/无本地 base 的 WebView 不会因此取得 file:// 权限。本地模式的主 frame(包括折叠到当前 WebView 的新窗口导航)不能跳到远程页面,网页消息仍只接受主 frame。
包含根相对链接、ES module、fetch、路由或完整静态站点时,推荐 http_server.start(url_mode="browser"):它提供正常 HTTP origin 语义,同时仍由当前会话的随机 token 和原生 lease 精确约束。简单离线文档和相对资源可直接使用本地 url / base_url。旧 MiniApp 保持 legacy profile 以兼容已有代码。
#MapView V2
region 可传 {"latitude", "longitude", "span", "longitude_span"} 或对应 Binding;用户平移、缩放后由 on_region_change 双向回写。标记推荐提供稳定 id,还可用 subtitle、system_image、tint。
overlays 支持:
{"type": "polyline", "coordinates": [[lat, lon], ...]}{"type": "polygon", "coordinates": [...], "fill_color": ...}{"type": "circle", "latitude": ..., "longitude": ..., "radius": ...}
controls 可选 compass、scale、user_location、pitch;interaction_modes 可为 "all"、"none" 或 pan / zoom 列表;camera_frequency="on_end" 适合业务状态,"continuous" 只用于确实需要连续地图反馈的场景。
import appui
state = appui.State(
region={"latitude": 31.2304, "longitude": 121.4737, "span": 0.08},
selected=None,
)
def marker_tapped(marker):
print(marker.get("title", ""))
def body():
return appui.MapView(
region=state.bind.region,
selection=state.bind.selected,
markers=[{
"id": "bund",
"latitude": 31.2400,
"longitude": 121.4900,
"title": "The Bund",
"system_image": "building.2.fill",
"tint": "systemBlue",
}],
overlays=[{
"id": "route",
"type": "polyline",
"coordinates": [[31.2304, 121.4737], [31.2400, 121.4900]],
"stroke_color": "systemBlue",
"line_width": 4,
}],
controls=["compass", "scale"],
on_marker_tap=marker_tapped,
).frame(height=320)
appui.run(body, state=state)
VideoPlayer(url=...) 适合只展示并播放一个视频。视频类 Mini App 需要恢复播放进度、切集、外部按钮控制、倍速、音量或 PiP 状态时,先创建 PlayerController,再传给 VideoPlayer(player=player)。AppUI 页面不要再额外 import avplayer 去控制同一块内嵌视频;PlayerController 已经是 AppUI 的播放器控制入口。
PlayerController 默认 pause_on_disappear=True,页面退出或视图消失时会暂停对应播放器,避免视频声音继续播放。确实需要离开页面后继续播放时,显式传 pause_on_disappear=False。
import appui
player = appui.PlayerController(
id="episode-player",
url="https://example.com/video.mp4",
autoplay=True,
allows_pip=True,
pause_on_disappear=True,
)
@player.on_progress(interval=5)
def save_progress(seconds):
print("progress", seconds)
def skip_forward():
player.seek(player.current_time + 30)
def body():
return appui.NavigationStack(
appui.VStack([
appui.VideoPlayer(player=player).frame(height=220),
appui.HStack([
appui.Button("Play", action=player.play),
appui.Button("Pause", action=player.pause),
appui.Button("Skip", action=skip_forward),
]),
], spacing=12)
.padding()
.navigation_title("Player")
)
appui.run(body)
import appui
def body():
markers = [
{"latitude": 35.68, "longitude": 139.76, "title": "Tokyo"},
{"latitude": 35.69, "longitude": 139.70, "title": "Shinjuku"},
]
return appui.NavigationStack(
appui.ScrollView(
appui.VStack([
appui.MapView(
latitude=35.68,
longitude=139.76,
span=0.12,
markers=markers,
map_style="standard",
).frame(height=220),
appui.WebView(html="<h1>AppUI</h1><p>Inline HTML content.</p>")
.frame(height=180),
], spacing=16)
.padding()
).navigation_title("Map & Web")
)
appui.run(body)
#加载状态和权限
| API | 空值或失败时的表现 | 建议 |
|---|---|---|
AsyncImage | 无占位时可能显示空白;失败时使用 error_view。 | 总是提供 placeholder 和 error_view。 |
PhotoPicker | 用户取消时可能返回空列表。 | 在 on_picked 中处理 [] 或 None。 |
CameraPicker | 用户拒绝权限、取消或设备不可用时可能没有路径。 | 在 on_captured 中处理空路径并显示说明。 |
FileImporter | 用户取消时不会产生有效路径;部分外部文件类型可能无法读取。 | 默认保持 copy=True,在 on_picked 中处理 [] 或 None。 |
VideoPlayer | 空 url 没有有效播放源。 | 文档示例可展示空控件,真实应用必须传可播放地址。 |
WebView | 未传 url / html 时没有明确内容。 | 显式传 URL 或 HTML。 |
MapView | 无标记也能显示中心点。 | span 不要过大,标记字典至少包含 latitude、longitude。 |
ShareLink | item 为空时分享面板没有有意义内容。 | 先在状态或函数里生成可读文本、URL 或文件路径。 |
#与相邻 API 的区别
| API | 不同点 |
|---|---|
Image vs Label | Image 只有图像;Label 是图标和标题组合,按钮和列表行更常用。 |
Image vs AsyncImage | Image 用本地资源或 SF Symbol;AsyncImage 从网络 URL 加载。 |
PhotoPicker vs CameraPicker | PhotoPicker 从已有媒体库选择;CameraPicker 创建新媒体。 |
PhotoPicker vs FileImporter | PhotoPicker 只面向相册媒体;FileImporter 从 Files App 或文档提供方导入普通文件。 |
WebView vs Link | WebView 在 AppUI 内嵌网页;Link 打开系统浏览器。 |
VideoPlayer vs WebView | 视频用 VideoPlayer,不要用 WebView 包一层视频网页来播放。 |
#常见错误
| 错误 | 正确做法 |
|---|---|
AsyncImage 不设固定高度,加载后布局跳动。 | 用 .frame(height=...) 固定媒体区域。 |
网络图片使用 "fill" 但忘记 .clipped()。 | 填充裁切时加 .clipped()。 |
| 相册或相机回调假设一定有路径。 | 对空列表、空字符串和权限拒绝做处理。 |
需要普通文档却用 PhotoPicker。 | 用 FileImporter(allowed_types=[...], on_picked=...)。 |
用 Image(...).on_tap(...) 模拟按钮。 | 可点击媒体动作使用 Button(content=appui.Image(...)) 或 Button(content=appui.Label(...))。 |
在用户文档里混用命令式 ui 写法。 | AppUI 文档只展示声明式 body() + appui.run(...)。 |