媒体
图片、相册、相机、地图、视频和 WebView 的页面模式。
appui 中与图片、相册、相机、文件导入、视频、网页与地图相关的视图。下列示例均可在应用内 AppUI 预览环境运行。
#预期效果
示例会展示图片、网络图、相册、文件、相机、视频、网页和地图控件如何嵌入 AppUI 页面。
#设计原则
- 资源图用
Image(name=...);SF Symbols 用Image(system_name=...)。 - 网络图用
AsyncImage,占位与失败视图通过子视图传入。 - 相册 / 相机返回的是当前 MiniApp 会话拥有的临时文件路径字符串;普通页面重建不会删除,MiniApp 停止时自动清理。
- Files App / 文档提供方用
FileImporter,默认复制到 App 可访问位置后返回路径列表。 - WebView 只能二选一:
url或html。 - MapView 的
markers为字典列表,键包括latitude、longitude、title。
#Image:本地资源与 SF Symbol
Image 支持链式修饰:resizable()、aspect_ratio(ratio, content_mode)、symbol_rendering_mode(mode)、image_scale(scale)。
import appui
state = appui.State(kind="SF Symbol")
def body():
return appui.NavigationStack(
appui.VStack([
appui.Text(state.kind).font("headline"),
appui.Image(system_name="star.fill")
.resizable()
.aspect_ratio(content_mode="fit")
.symbol_rendering_mode("palette")
.image_scale("large")
.frame(width=56, height=56)
.foreground_color("systemYellow"),
appui.Label("设置", system_image="gear"),
], spacing=16).padding()
.navigation_title("Image")
)
appui.run(body, state=state, presentation="sheet")
content_mode 为 'fit' 或 'fill'。symbol_rendering_mode 常用:'hierarchical'、'palette'、'multicolor'(未识别时回退为单色)。
image_scale 为 'small'、'medium'(默认)、'large'。
#AsyncImage:网络图片
参数:url、placeholder、error_view、content_mode、on_success、on_failure。占位与错误视图为子节点(前两个子视图依次为占位、错误)。
import appui
state = appui.State(status="等待加载")
def image_loaded():
state.status = "图片已加载"
def image_failed():
state.status = "图片加载失败"
def body():
return appui.NavigationStack(
appui.VStack([
appui.AsyncImage(
url="https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png",
placeholder=appui.ProgressView(label="Loading"),
error_view=appui.Image(system_name="exclamationmark.triangle"),
content_mode="fit",
on_success=image_loaded,
on_failure=image_failed,
).frame(height=120),
appui.LabeledContent("状态", value=state.status),
], spacing=16).padding()
.navigation_title("AsyncImage")
)
appui.run(body, state=state, presentation="sheet")
#PhotoPicker:相册
selection_limit:1 表示单选;0 表示不限制(以系统行为为准)。filter:'images'、'videos'、'all'。on_picked 收到 路径列表,on_error 收到结构化错误。媒体使用文件式传输,选择大视频时不会先把完整文件读入内存。
import appui
state = appui.State(paths=[])
def on_picked(paths):
state.paths = paths or []
def on_pick_error(error):
state.paths = [error.get("message", "选择失败")]
def body():
return appui.VStack([
appui.Text("已选: " + (" | ".join(state.paths) if state.paths else "无")).font("caption"),
appui.PhotoPicker(
selection_limit=1,
filter="images",
on_picked=on_picked,
on_error=on_pick_error,
label=appui.Label("选择照片", system_image="photo.on.rectangle"),
),
], spacing=12).padding()
appui.run(body, state=state, presentation="sheet")
#FileImporter:文件导入
allowed_types 可传类型名、扩展名或 MIME 类型,例如 'text'、'pdf'、'csv'、'image/png'。allows_multiple=True 允许多选。copy=True 是默认值,表示先复制进 App 可访问位置,再把路径列表传给 on_picked。
import appui
state = appui.State(files=[])
def on_files(paths):
state.files = paths or []
def body():
rows = [
appui.Text(path).font("caption").line_limit(1)
for path in state.files
]
return appui.NavigationStack(
appui.Form([
appui.Section("导入", [
appui.FileImporter(
allowed_types=["text", "pdf", "csv"],
allows_multiple=True,
on_picked=on_files,
label=appui.Label("选择文件", system_image="doc.badge.plus"),
),
]),
appui.Section("文件", rows or [
appui.ContentUnavailableView("暂无文件", system_image="doc", description="从系统文件选择器导入")
]),
]).navigation_title("文件导入")
)
appui.run(body, state=state, presentation="sheet")
文件选择由系统界面完成。默认 copy=True 会在安全访问范围内复制到会话临时目录,最适合普通 MiniApp;copy=False 只在视图存活期间保持外部文件授权。需要读取文件内容时,在回调里保存路径,再在按钮动作、刷新函数或后台流程中读取,避免在 body() 里同步读大文件。
#CameraPicker:相机
source:'camera'、'rear'、'back' 或 'front'。media_type:'photo' 或 'video'。on_captured 收到单个路径字符串;取消走 on_cancel,失败走 on_error。照片可配置 quality 与 allows_editing;视频可配置 video_quality 与 maximum_duration。
import appui
state = appui.State(last="")
def on_captured(path):
state.last = path or ""
def on_cancel():
state.last = "已取消"
def on_error(error):
state.last = error.get("message", "相机不可用")
def body():
return appui.VStack([
appui.Text(state.last or "尚未拍摄").font("caption"),
appui.CameraPicker(
source="camera",
media_type="photo",
on_captured=on_captured,
on_cancel=on_cancel,
on_error=on_error,
quality=0.9,
label=appui.Label("拍照", system_image="camera.fill"),
),
], spacing=12).padding()
appui.run(body, state=state, presentation="sheet")
#VideoPlayer:AVKit
url 可为远程 HTTPS 或应用内可访问的本地文件名。autoplay、loop、show_controls 控制播放行为。
只展示视频时直接用 VideoPlayer(url=...)。如果页面需要播放/暂停/seek、保存进度、倍速、PiP 状态或切集,使用 PlayerController 并传给 VideoPlayer(player=player);AppUI 新页面不要再 import avplayer 控制同一块内嵌视频。
import appui
state = appui.State(status="可播放")
def body():
return appui.NavigationStack(
appui.VStack([
appui.VideoPlayer(
url="https://media.w3.org/2010/05/sintel/trailer.mp4",
autoplay=False,
loop=False,
show_controls=True,
).frame(height=220),
appui.LabeledContent("状态", value=state.status),
], spacing=16).padding()
.navigation_title("VideoPlayer")
)
appui.run(body, state=state, presentation="sheet")
#WebView:URL 或 HTML
WebView 支持双向导航状态、前进/后退/刷新/停止、JavaScript 执行、网页消息、Cookie/网站数据命令、原生下载、临时数据仓库和 host allowlist。命令必须带稳定 id,这样 UI 重建不会重复执行同一命令。
import appui
state = appui.State()
def body():
return appui.WebView(
html="<html><body style='font-family:system-ui'><h1>appui</h1><p>内嵌 HTML</p></body></html>"
).frame(height=360).padding()
appui.run(body, state=state, presentation="sheet")
远程页面示例:appui.WebView(url="https://www.apple.com").frame(height=360)。声明了 miniapp.json capabilities 的 MiniApp 还需要 network 与匹配的 allowedNetworkHosts;控件自己的 allowed_hosts 只能进一步缩小范围。两者都限制为最多 128 条、UTF-8 总计最多 32768 字节,超限或含非法规则时安全拒绝。运行时会再次按 MiniApp 的不可变权限清单核验页面声明,页面节点不能自行扩权;同一有效集合用于导航、HTTPS/WSS 子资源、Cookie 和网站数据,其他分层网络 scheme 默认阻断。权限快速变化时,每个 WebView 只保留一个正在编译和一个最新待编译策略,全局编译队列也有界。
若页面来自当前 MiniApp 的原生 http_server,使用 http_server.start(port=0, url_mode="browser") 返回的地址。宿主只对当前运行的精确随机 localhost origin 开临时授权,因而根相对 CSS/JS/图片可以正常加载;无需也不应把动态 hostname 写入 allowedNetworkHosts。server 停止、替换或 MiniApp 会话结束后,该授权和连接同时失效。默认 url_mode="universal" 更适合 URLSession/普通客户端,但其令牌位于 path 中,不适合作为含 /style.css 的网页根。
本地内容可以传绝对路径或 file:// URL。声明式 MiniApp 读取自身工作区时需要 file_system;系统分享或外部启动交付给当前运行的 LaunchItem.path 属于 host-issued 临时只读授权,不需要额外声明 file_system,但不能写入、不能跨运行保存授权,也不能借此访问工作区或授权根以外的路径。WebView(url=本地文件) 会先规范化、确认文件真实存在且位于当前运行的可读根,再由原生 loadFileURL 限制读取范围。内联 HTML 可用 WebView(html=..., base_url=本地目录或文件) 加载同一授权根内的 CSS、JS 与图片,授权根目录本身也可作为 base_url。只有创建时已进入本地模式的 WebView 才能用 load_url 切换本地文件,且目标仍须位于同一原生范围;远程页面不会继承文件权限,本地页面的主 frame(包括 target=_blank 被折叠到当前视图的导航)也不能跳到远程页面。若页面依赖根相对 URL、ES module、fetch 或前端路由,仍推荐上面的 http_server browser URL,以获得完整 HTTP origin 语义。
WebView 的权限固定归属创建它的 AppUI 运行会话。Tab/页面重建和合法 generation 切换不会误撤权;MiniApp 停止、宿主任务取消或会话结束时,页面会停止并清空,下载与回调资源一并取消,旧 WebView 不会借下一次运行恢复。
需要下载时同时提供 allows_downloads=True 和命名的 on_download(path) 回调,并声明 file_system。返回路径是当前运行会话拥有的临时文件:UI 重建不会令它失效,MiniApp 停止后会自动删除。Cookie 与网站数据命令为 get_cookies、set_cookie、delete_cookie、clear_website_data;每条命令都必须有新的稳定 id。
#MapView:地图与标注
默认中心为旧金山坐标。span 为经纬跨度。map_style 可为 'automatic'、'standard'、'satellite'、'imagery'、'hybrid'。生产页面优先用 region=state.bind.region 双向同步原生相机;标记提供稳定 id,轨迹与区域使用 polyline / polygon / circle overlays。
import appui
state = appui.State()
def body():
return appui.MapView(
latitude=37.7749,
longitude=-122.4194,
span=0.08,
markers=[
{"latitude": 37.78, "longitude": -122.40, "title": "标记 A"},
],
map_style="standard",
).frame(height=280).padding()
appui.run(body, state=state, presentation="sheet")
#与旧版 ui 模块对照(迁移)
ui 为 Pythonista 风格命令式 API;同一应用内可对照理解差异:
import ui
v = ui.View()
b = ui.Button(title="Go")
def on_button(sender):
pass
b.action = on_button
v.add_subview(b)
# v.present('sheet') # 需在 AppUI 预览环境中调用
appui 用声明式 body() 返回 View,由 appui.run(body, state=..., presentation="sheet") 呈现。
#小结
| 视图 | 典型用途 |
|---|---|
Image | 资源图、SF Symbol、缩放与宽高比 |
AsyncImage | 网络图片与阶段 UI |
PhotoPicker / CameraPicker | 系统相册与相机,路径回调 |
FileImporter | 系统文件选择器,路径列表回调 |
VideoPlayer | 流媒体或本地视频 |
WebView | url 或 html 嵌入网页 |
MapView | 坐标、跨度、标注与样式 |
更完整的参数说明见同目录下的 appui-ref-media.md。