PythonIDE Docs
中文
简体中文

媒体 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、中心点、缩放跨度和标记。

#最小正确示例

python
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签名分类
ImageImage(name: Optional[str] = None, system_name: Optional[str] = None, systemName: Optional[str] = None)media
LabelLabel(title: str = '', system_image: Optional[str] = None, image: Optional[str] = None, systemImage: Optional[str] = None)text
AsyncImageAsyncImage(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"
python
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签名分类
PhotoPickerPhotoPicker(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
CameraPickerCameraPicker(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
FileImporterFileImporter(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) 接收包含 domaincodemessage 的字典;相机取消使用独立的 on_cancel(),不会伪装成成功或错误。

选择器返回的临时文件属于当前 MiniApp 运行会话:普通 UI 重建、Tab 切换和热更新 generation 不会提前删除;MiniApp 真正停止后自动清理。要跨运行长期保存,请在回调中复制到自己的项目或存储目录。

API生产级行为
PhotoPicker使用文件式媒体传输,不把整张照片或视频一次性读入内存;新选择会取消旧传输,取消或失败会清理半成品。
CameraPickerquality 控制 JPEG 质量;allows_editing 使用系统裁剪;录像可设置 video_qualitymaximum_duration
FileImporter(copy=True)在安全访问范围内复制到应用临时目录后回调,最适合普通 MiniApp。
FileImporter(copy=False)返回提供方 URL,并在视图存活期间保持安全访问;不要把该路径当作永久授权。
python
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)
python
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签名分类
VideoPlayerVideoPlayer(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
PlayerControllerPlayerController(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)公开类型
WebViewWebView(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
MapViewMapView(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
ShareLinkShareLink(item: str = '', subject: Optional[str] = None, message: Optional[str] = None)control

#WebView V2

WebView 是生命周期归属当前节点的可编程浏览器,而不是一次性网页截图。navigation_state 可传 Binding,收到:phaseurltitleprogresscan_go_backcan_go_forward。网页可通过 window.webkit.messageHandlers.appui.postMessage(value)on_message 发送 JSON 兼容值。

命令使用带稳定 id 的字典;同一个 id 只执行一次,避免页面重建重复刷新、重复执行脚本或重复导航。

python
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:reloadstopgo_backgo_forwardevaluate_javascriptload_urlload_htmlget_cookiesset_cookiedelete_cookieclear_website_data。Cookie 命令和网站数据清理需要 network;声明式安全 profile 只会读取、修改或清除 allowlist 内域名的数据,不会暴露或删除其他 MiniApp 的站点数据。clear_website_datadata_types 可选 cookiesdisk_cachememory_cachelocal_storagesession_storageindexed_dbservice_workers;省略表示全部。

下载必须同时传 allows_downloads=Trueon_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.jsonallowedNetworkHosts 的范围,不能扩大;两者都限制为最多 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,还可用 subtitlesystem_imagetint

overlays 支持:

  • {"type": "polyline", "coordinates": [[lat, lon], ...]}
  • {"type": "polygon", "coordinates": [...], "fill_color": ...}
  • {"type": "circle", "latitude": ..., "longitude": ..., "radius": ...}

controls 可选 compassscaleuser_locationpitchinteraction_modes 可为 "all""none"pan / zoom 列表;camera_frequency="on_end" 适合业务状态,"continuous" 只用于确实需要连续地图反馈的场景。

python
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

python
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)
python
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总是提供 placeholdererror_view
PhotoPicker用户取消时可能返回空列表。on_picked 中处理 []None
CameraPicker用户拒绝权限、取消或设备不可用时可能没有路径。on_captured 中处理空路径并显示说明。
FileImporter用户取消时不会产生有效路径;部分外部文件类型可能无法读取。默认保持 copy=True,在 on_picked 中处理 []None
VideoPlayerurl 没有有效播放源。文档示例可展示空控件,真实应用必须传可播放地址。
WebView未传 url / html 时没有明确内容。显式传 URL 或 HTML。
MapView无标记也能显示中心点。span 不要过大,标记字典至少包含 latitudelongitude
ShareLinkitem 为空时分享面板没有有意义内容。先在状态或函数里生成可读文本、URL 或文件路径。

#与相邻 API 的区别

API不同点
Image vs LabelImage 只有图像;Label 是图标和标题组合,按钮和列表行更常用。
Image vs AsyncImageImage 用本地资源或 SF Symbol;AsyncImage 从网络 URL 加载。
PhotoPicker vs CameraPickerPhotoPicker 从已有媒体库选择;CameraPicker 创建新媒体。
PhotoPicker vs FileImporterPhotoPicker 只面向相册媒体;FileImporter 从 Files App 或文档提供方导入普通文件。
WebView vs LinkWebView 在 AppUI 内嵌网页;Link 打开系统浏览器。
VideoPlayer vs WebView视频用 VideoPlayer,不要用 WebView 包一层视频网页来播放。

#常见错误

错误正确做法
AsyncImage 不设固定高度,加载后布局跳动。.frame(height=...) 固定媒体区域。
网络图片使用 "fill" 但忘记 .clipped()填充裁切时加 .clipped()
相册或相机回调假设一定有路径。对空列表、空字符串和权限拒绝做处理。
需要普通文档却用 PhotoPickerFileImporter(allowed_types=[...], on_picked=...)
Image(...).on_tap(...) 模拟按钮。可点击媒体动作使用 Button(content=appui.Image(...))Button(content=appui.Label(...))
在用户文档里混用命令式 ui 写法。AppUI 文档只展示声明式 body() + appui.run(...)

#相关文档

文档用途
控件 APIButton、Label、Picker 和输入控件。
图表与画布 APIChart、Canvas、DrawingContext、Path。
性能指南媒体和大数据刷新时的性能边界。