uni-app 全局容器实战系列(四):全局容器动态调用设计

uni-app 全局容器实战系列(四):全局容器动态调用设计


一、承接上文

第三章遗留问题

第三章我们实现了配置驱动的 NavBar 和 TabBar 组件 ,但存在一个限制:页面只能被动读取配置,无法主动修改容器的状态

实际业务中,页面需要动态与全局容器交互:

  • 页面加载后根据数据动态修改导航栏标题
  • 根据业务状态动态改变标题颜色
  • 控制标题栏的折叠/展开行为
  • 触发未登录提示弹窗

本章目标

setTitle
setTitleColor
showUnloginNotify
页面
AppLayout

实现页面主动修改容器状态的能力。


二、设计背景

2.1 传统方案的问题

方案 问题
uni.setNavigationBarTitle() 仅支持标题修改,无法改颜色、样式
组件间 props 传递 需要手动在每个页面引入布局组件
全局变量共享 缺乏生命周期管理,容易内存泄漏

2.2 设计目标

目标 描述
统一入口 页面通过 useAppLayout() 获取操作 API
异步安全 确保组件挂载后再执行操作
自动清理 页面卸载时自动移除监听,防止内存泄漏
类型安全 完整的 TypeScript 类型提示

三、核心设计

3.1 整体架构

registerLayout AppLayout.vue uni.emit useAppLayout 页面组件 registerLayout AppLayout.vue uni.emit useAppLayout 页面组件 调用 useAppLayout() uni.$emit('SET_TITLE_EVENT') 触发事件监听 更新 userTitle ref 响应式渲染新标题

3.2 事件通信机制

页面与组件通过 uni.$emit / uni.$on 进行事件通信:

javascript 复制代码
// 事件命名规范:{pageId}:APPLAYOUT:{METHOD}

// 示例:
// 'pages/index/index:0:APPLAYOUT:SET_TITLE'
//  pageId              namespace   method

为什么用 page.route + ':' + page.$page.id

组成 示例 作用
page.route pages/index/index 页面路径标识
page.$page.id 0 页面实例唯一 ID
组合结果 pages/index/index:0 多实例页面时 ID 唯一

为什么需要保证唯一性?

在 tabBar 切换场景中,同一页面路径可能存在多个实例:
tabBar 实例
切换
切换
切换
pages/index/index:0
pages/index/index:1
pages/index/index:2

3.3 异步等待机制

页面调用 API 时,组件可能尚未挂载完成。使用 Promise 机制确保操作在组件就绪后执行:
AppLayout 组件 页面组件 AppLayout 组件 页面组件 此时 Layout 尚未完成 onMounted 事件丢失! 正确做法:先等待注册完成 onMounted() 调用 setTitle() uni.emit('SET_TITLE_EVENT') uni.on() 尚未注册 await getRegisterLayoutPromise(pageId) uni.emit() 时机正确 uni.on() 已就绪

promiseWithResolvers 实现原理

为什么需要这个模式?

标准 Promise 的 resolvereject 无法从外部获取:

javascript 复制代码
// ❌ 普通 Promise 无法从外部 resolve
const promise = new Promise((resolve) => {
    // resolve 只能在 Promise 内部调用
    // 外部无法访问到这个 resolve 函数
})

promiseWithResolvers 的核心思想

javascript 复制代码
// promiseWithResolvers.js
export const promiseWithResolvers = () => {
    // 在函数内部创建 Promise,同时提取 resolve/reject
    let resolve, reject

    const promise = new Promise((res, rej) => {
        // 将 resolve/reject 赋值给外部变量
        resolve = res
        reject = rej
    })

    // 返回 Promise 本身 + resolve/reject 函数
    return { promise, resolve, reject }
}

工作流程图解

复制代码
┌─────────────────────────────────────────────────────────────┐
│                    第一步:初始化                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   registerLayoutCache.set(pageId, promiseWithResolvers())   │
│                                                             │
│   ┌─────────────────────────────────────┐                   │
│   │ pageId: {                            │                   │
│   │   promise: Promise (pending),        │ ← 外部持有引用    │
│   │   resolve: [Function],               │ ← 外部持有引用    │
│   │   reject: [Function]                 │ ← 外部持有引用    │
│   │ }                                    │                   │
│   └─────────────────────────────────────┘                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                    第二步:组件注册                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   // AppLayout onMounted 时调用                             │
│   resolveRegisterLayout(pageId)  →  resolve() 执行          │
│                                                             │
│   ┌─────────────────────────────────────┐                   │
│   │ pageId: {                            │                   │
│   │   promise: Promise (resolved!),     │ ← pending → resolved │
│   │   resolve: [Function],               │                   │
│   │   reject: [Function]                 │                   │
│   │ }                                    │                   │
│   └─────────────────────────────────────┘                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                    第三步:页面调用                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   // 页面调用 useAppLayout().setTitle()                    │
│   await getRegisterLayoutPromise(pageId)                    │
│   // 此时 Promise 已经是 resolved,立即执行后续代码          │
│   uni.$emit(...)  →  事件正确送达                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
page.$page.id 的来源

uni-app 页面实例具有 $page 属性,记录页面的元信息:

javascript 复制代码
// 获取当前页面实例
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]

// currentPage.$page 结构:
{
    id: 0,           // ← 页面实例 ID(递增,每次进入页面累加)
    route: 'pages/index/index',  // 页面路径
    options: {}      // 页面参数
}

为什么需要 $page.id

场景 问题 解决方案
首次进入首页 只有一个实例,ID = 0 pages/index/index:0
再次进入首页 已有实例,ID = 1 pages/index/index:1
第三次进入 ID = 2 pages/index/index:2

多实例场景示例

javascript 复制代码
// TabBar 页面在 uni-app 中会被缓存在内存中
// 假设用户依次访问了 index → list → detail → index(回到首页)

// 此时内存中存在 3 个 index 页面实例:
// - index:0 (首次进入,已离开但未销毁)
// - index:1 (第二次进入)
// - index:2 (当前显示)

// 事件 'pages/index/index:0:APPLAYOUT:SET_TITLE'
// 只会影响 index:0 这个特定实例,不会影响 index:1 或 index:2

3.4 Map 缓存清理的必要性

内存泄漏场景分析

如果不清理 Map 缓存,会导致以下问题:

javascript 复制代码
// ❌ 错误做法:onUnmounted 中没有清理
onUnmounted(() => {
    uni.$off(...)  // 清理了事件监听
    // ❌ 但没有清理 registerLayoutCache 中的 Promise
    // registerLayoutCache.delete(pageId) 被遗漏
})

内存泄漏场景

复制代码
┌──────────────────────────────────────────────────────────────┐
│                    用户访问页面生命周期                        │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  1. 进入页面 A                                                │
│     → registerLayoutCache.set('A:0', PromiseWithResolvers)   │
│     → 缓存大小: 1 entry                                       │
│                                                              │
│  2. 离开页面 A                                                │
│     → 事件监听已清理 (uni.$off)                               │
│     → ❌ 缓存未清理                                           │
│     → 缓存大小: 1 entry (僵尸数据)                            │
│                                                              │
│  3. 进入页面 A 10 次                                          │
│     → 缓存大小: 10 entries (全部是僵尸数据)                   │
│     → 内存持续增长!                                          │
│                                                              │
└──────────────────────────────────────────────────────────────┘

内存泄漏后果

问题 影响
Map 持续膨胀 长时间使用后内存占用不断增加
Promise 对象无法 GC 虽然 Promise 已 resolved,但其引用仍在 Map 中
旧页面数据残留 再次进入同名页面可能读取到旧的缓存数据
正确清理流程

uni.emit/on registerLayoutCache AppLayout 组件 uni.emit/on registerLayoutCache AppLayout 组件 onMounted onUnmounted Map 中无残留数据 组件完全销毁,无内存泄漏 set(pageId, promiseWithResolvers()) uni.on(...) 注册事件 uni.off(...) 清理所有事件监听 delete(pageId) 清理缓存

完整清理代码

javascript 复制代码
// 在 registerLayout 中
onUnmounted(() => {
    // 1. 清理所有事件监听
    uni.$off(getSetTitleEventName(pageId))
    uni.$off(getSetTitleColorEventName(pageId))
    uni.$off(getSetTitleCollapseEventName(pageId))
    uni.$off(getShowUnloginNotifyEventName(pageId))

    // 2. 清理缓存(必须!)
    cleanUpRegisterLayout(pageId)  // → registerLayoutCache.delete(pageId)
})

3.5 事件命名唯一性保证

为什么需要三层唯一性?
复制代码
{pageId}:APPLAYOUT:{METHOD}
   ↓        ↓         ↓
 pages/    命名空间    具体方法
 index/    隔离其他    SET_TITLE
 index:0   业务        SET_TITLE_COLOR
           事件        SET_TITLE_COLLAPSE
层级 作用 示例
pageId 区分不同页面实例 pages/index/index:0
APPLAYOUT 隔离其他业务事件 避免与 WxLoginAnalytics 等冲突
METHOD 区分不同操作 SET_TITLE vs SET_TITLE_COLOR
TabBar 多实例场景详解

uni-app 中 TabBar 页面存在多实例复用机制:
渲染错误: Mermaid 渲染失败: Parse error on line 20: ...-->|激活| C Note over MultiInstances: ---------------------^ Expecting 'SEMI', 'NEWLINE', 'EOF', 'AMP', 'START_LINK', 'LINK', 'LINK_ID', got 'NODE_STRING'

实例 ID 分配规则

操作 实例变化
首次打开小程序 index:0 被创建
切换到 list index:0 缓存,list:0 创建
切换到 profile list:0 缓存,profile:0 创建
切回 index profile:0 缓存,index:0 激活
再次切换到 list index:0 缓存,list:1 创建(ID 递增)

事件隔离验证

javascript 复制代码
// 假设当前页面是 index:2
// 调用 setTitle('新标题')

// 事件名:'pages/index/index:2:APPLAYOUT:SET_TITLE'

// 只会影响 index:2 这个实例
// index:0、index:1 不会受到影响
事件冲突场景与避免

可能冲突的场景

场景 问题 解决
不同页面同名事件 A:0:SET_TITLEB:0:SET_TITLE pageId 包含完整路径
关闭页面再打开 新实例 ID 不同 ID 由框架分配,无法预测
多小程序共享代码 事件名可能相同 APPLAYOUT 命名空间隔离

四、API 设计

4.1 useAppLayout Hook

typescript 复制代码
interface AppLayoutAPI {
    setTitle: (title: string) => Promise<void>
    setTitleColor: (color: string) => Promise<void>
    setTitleCollapse: (collapse: boolean) => Promise<void>
    showUnloginNotify: () => Promise<void>
}

/**
 * 页面通信入口 Hook
 * 页面通过此 Hook 获取操作 AppLayout 的 API
 */
export const useAppLayout = (): AppLayoutAPI => {
    const pageId = getPageId()

    const setTitle = async (title: string) => {
        await getRegisterLayoutPromise(pageId)
        uni.$emit(getSetTitleEventName(pageId), title)
    }

    const setTitleColor = async (color: string) => {
        await getRegisterLayoutPromise(pageId)
        uni.$emit(getSetTitleColorEventName(pageId), color)
    }

    const setTitleCollapse = async (collapse: boolean) => {
        await getRegisterLayoutPromise(pageId)
        uni.$emit(getSetTitleCollapseEventName(pageId), collapse)
    }

    const showUnloginNotify = async () => {
        await getRegisterLayoutPromise(pageId)
        uni.$emit(getShowUnloginNotifyEventName(pageId))
    }

    return { setTitle, setTitleColor, setTitleCollapse, showUnloginNotify }
}

4.2 registerLayout 注册函数

typescript 复制代码
/**
 * 布局事件注册 - 仅在 AppLayout 组件中调用
 */
export const registerLayout = ({
    setTitle,
    setTitleColor,
    setTitleCollapse,
    showUnloginNotify
}) => {
    const pageId = getPageId()

    onMounted(() => {
        // 监听来自页面的事件
        uni.$on(getSetTitleEventName(pageId), (title) => setTitle(title))
        uni.$on(getSetTitleColorEventName(pageId), (color) => setTitleColor(color))
        uni.$on(getSetTitleCollapseEventName(pageId), (collapse) => setTitleCollapse(collapse))
        uni.$on(getShowUnloginNotifyEventName(pageId), () => showUnloginNotify())

        // 标记组件已就绪,唤醒等待的 Promise
        resolveRegisterLayout(pageId)
    })

    onUnmounted(() => {
        // 清理事件监听,防止内存泄漏
        uni.$off(getSetTitleEventName(pageId))
        uni.$off(getSetTitleColorEventName(pageId))
        uni.$off(getSetTitleCollapseEventName(pageId))
        uni.$off(getShowUnloginNotifyEventName(pageId))

        // 清理注册缓存
        cleanUpRegisterLayout(pageId)
    })
}

五、NavBar 特性实现

5.1 标题动态修改

页面调用

javascript 复制代码
const { setTitle } = useAppLayout()

onMounted(async () => {
    await setTitle('商品详情')
})

组件实现

javascript 复制代码
// AppLayout.vue
const userTitle = ref(null)  // 用户设置的标题(优先级更高)

const layoutProps = computed(() => ({
    titleText: userTitle.value || navigationBarTitleText,  // 优先使用用户设置的值
}))

registerLayout({
    setTitle: (title) => { userTitle.value = title },
})

5.2 标题颜色动态修改

页面调用

javascript 复制代码
const { setTitleColor } = useAppLayout()

// 修改标题为白色
await setTitleColor('#FFFFFF')

组件实现

javascript 复制代码
// AppLayout.vue
const userTitleColor = ref(null)

const layoutProps = computed(() => ({
    titleColor: userTitleColor.value || navigationBarTextStyle?.color || 'black',
}))

registerLayout({
    setTitleColor: (color) => { userTitleColor.value = color },
})

5.3 标题折叠控制

使用场景:滚动页面时,标题栏内容需要折叠隐藏


页面滚动
scrollTop <= threshold?
显示标题
隐藏标题

页面调用

javascript 复制代码
const { setTitleCollapse } = useAppLayout()

// 禁用标题折叠效果
await setTitleCollapse(false)

六、TabBar 特性实现

6.1 未登录提示设计

为什么将弹窗挂载在 AppLayout 而不是页面?

方案 问题 解决
每个页面自己引入弹窗 代码重复、状态不统一 AppLayout 统一提供
全局变量直接控制 缺乏生命周期管理 通过 API 封装
直接操作组件实例 组件未挂载时报错 Promise 等待机制

6.2 组件实现

UnloginNotify.vue - 未登录提示弹窗组件

vue 复制代码
<template>
    <uv-popup ref="popup" bgColor="transparent" :customStyle="{ overflow: 'visible' }">
        <view class="popup-content">
            <image class="illustration" src="@/static/images/login/img_login_illustration.png" />
            <image class="text" src="@/static/images/login/img_login_text.png" />
            <view class="actions">
                <button class="cancel-btn" @click="onCancel">取消</button>
                <button class="confirm-btn" @click="onConfirm">登录</button>
            </view>
        </view>
    </uv-popup>
</template>

<script setup>
import { ref } from 'vue'
import useUserStore from '@/store/modules/user'
import { loginPage } from '@/permission'

const popup = ref(null)

const onCancel = () => popup.value?.close()

const onConfirm = () => {
    useUserStore().logOut().then(() => {
        uni.reLaunch({ url: loginPage })
    })
}

defineExpose({
    open() {
        popup.value?.open()
    },
})
</script>

AppLayout 集成

vue 复制代码
<!-- AppLayout.vue -->
<template>
    <Layout v-bind="layoutProps">
        <slot></slot>
        <UnloginNotify ref="unloginNotify" />
    </Layout>
</template>

<script setup>
import { ref } from 'vue'
import UnloginNotify from './UnloginNotify.vue'

const unloginNotify = ref(null)

registerLayout({
    showUnloginNotify: () => { unloginNotify.value?.open() },
})
</script>

页面调用

vue 复制代码
<script setup>
import { useAppLayout } from '@/components/AppLayout/useAppLayout'
import { useUserStore } from '@/stores/user'

const { showUnloginNotify } = useAppLayout()
const userStore = useUserStore()

// 检测未登录状态并触发弹窗
if (!userStore.isLogin) {
    await showUnloginNotify()
}
</script>

七、完整代码

7.1 promiseWithResolvers.js

javascript 复制代码
export const promiseWithResolvers = () => {
    let resolve, reject
    const promise = new Promise((res, rej) => {
        resolve = res
        reject = rej
    })
    return { promise, resolve, reject }
}

7.2 useAppLayout.js

javascript 复制代码
import { onMounted, onUnmounted } from "vue"
import { promiseWithResolvers } from "./promise-with-resolvers"

const registerLayoutCache = new Map()

const getRegisterLayout = (pageId) => {
    if (!registerLayoutCache.get(pageId)) {
        registerLayoutCache.set(pageId, promiseWithResolvers())
    }
    return registerLayoutCache.get(pageId)
}

const getRegisterLayoutPromise = (pageId) => getRegisterLayout(pageId).promise
const resolveRegisterLayout = (pageId) => getRegisterLayout(pageId).resolve
const cleanUpRegisterLayout = (pageId) => registerLayoutCache.delete(pageId)

const getPageId = () => {
    const page = getCurrentPages()[getCurrentPages().length - 1]
    return page.route + ':' + page.$page.id
}

const APPLAYPOUTNS = 'APPLAYOUT'
const genEventName = (method, ns, pageId) => `${pageId}:${ns}:${method}`

const METHOD_SET_TITLE = 'SET_TITLE'
const getSetTitleEventName = (pageId) => genEventName(METHOD_SET_TITLE, APPLAYPOUTNS, pageId)

const METHOD_SET_TITLE_COLOR = 'SET_TITLE_COLOR'
const getSetTitleColorEventName = (pageId) => genEventName(METHOD_SET_TITLE_COLOR, APPLAYPOUTNS, pageId)

const METHOD_SET_TITLE_COLLAPSE = 'SET_TITLE_COLLAPSE'
const getSetTitleCollapseEventName = (pageId) => genEventName(METHOD_SET_TITLE_COLLAPSE, APPLAYPOUTNS, pageId)

const METHOD_SHOW_UNLOGIN_NOTIFY = 'SHOW_UNLOGIN_NOTIFY'
const getShowUnloginNotifyEventName = (pageId) => genEventName(METHOD_SHOW_UNLOGIN_NOTIFY, APPLAYPOUTNS, pageId)

export const useAppLayout = () => {
    const pageId = getPageId()

    const setTitle = async (title) => {
        await getRegisterLayoutPromise(pageId)
        uni.$emit(getSetTitleEventName(pageId), title)
    }

    const setTitleColor = async (color) => {
        await getRegisterLayoutPromise(pageId)
        uni.$emit(getSetTitleColorEventName(pageId), color)
    }

    const setTitleCollapse = async (collapse) => {
        await getRegisterLayoutPromise(pageId)
        uni.$emit(getSetTitleCollapseEventName(pageId), collapse)
    }

    const showUnloginNotify = async () => {
        await getRegisterLayoutPromise(pageId)
        uni.$emit(getShowUnloginNotifyEventName(pageId))
    }

    return { setTitle, setTitleColor, setTitleCollapse, showUnloginNotify }
}

export const registerLayout = ({
    setTitle,
    setTitleColor,
    setTitleCollapse,
    showUnloginNotify
}) => {
    const pageId = getPageId()

    onMounted(() => {
        uni.$on(getSetTitleEventName(pageId), (title) => setTitle(title))
        uni.$on(getSetTitleColorEventName(pageId), (color) => setTitleColor(color))
        uni.$on(getSetTitleCollapseEventName(pageId), (collapse) => setTitleCollapse(collapse))
        uni.$on(getShowUnloginNotifyEventName(pageId), () => showUnloginNotify())

        resolveRegisterLayout(pageId)
    })

    onUnmounted(() => {
        uni.$off(getSetTitleEventName(pageId))
        uni.$off(getSetTitleColorEventName(pageId))
        uni.$off(getSetTitleCollapseEventName(pageId))
        uni.$off(getShowUnloginNotifyEventName(pageId))

        cleanUpRegisterLayout(pageId)
    })
}

八、使用示例

8.1 基础用法

vue 复制代码
<script setup>
import { useAppLayout } from '@/components/AppLayout/useAppLayout'

const { setTitle, setTitleColor } = useAppLayout()

onMounted(async () => {
    await setTitle('商品详情')
    await setTitleColor('#FFFFFF')
})
</script>

<template>
    <view>页面内容</view>
</template>

8.2 组合式使用

vue 复制代码
<script setup>
import { useAppLayout } from '@/components/AppLayout/useAppLayout'
import { useUserStore } from '@/stores/user'

const { setTitle, showUnloginNotify } = useAppLayout()
const userStore = useUserStore()

onMounted(async () => {
    const goods = await fetchGoods()

    if (goods.isLoginRequired && !userStore.isLogin) {
        await showUnloginNotify()
    } else {
        await setTitle(goods.name)
    }
})
</script>

九、设计总结

9.1 核心优势

特性 实现方式
异步安全 Promise + promiseWithResolvers 等待组件就绪
内存安全 onUnmounted 自动清理事件监听
类型安全 完整的 TypeScript 类型定义
统一入口 useAppLayout() Hook 提供一致 API

9.2 事件命名规范

复制代码
{pageId}:APPLAYOUT:{METHOD}
组成部分 说明 示例
pageId 确保多实例隔离 pages/index/index:0
APPLAYOUT 命名空间,避免冲突 APPLAYOUT
METHOD 操作类型 SET_TITLESET_TITLE_COLOR

9.3 扩展思路

基于当前架构,可以轻松扩展更多 API:

API 说明
setNavBg(color) 修改导航栏背景色
setTabBg(color) 修改 TabBar 背景色
hideNavBar() 隐藏导航栏
hideTabBar() 隐藏 TabBar
setBadge(index, count) 设置 TabBar 微标

useAppLayout Hook
setTitle
setTitleColor
setTitleCollapse
showUnloginNotify
扩展 API
setNavBg
setTabBg
hideNavBar
hideTabBar
setBadge


十、系列总结

四章回顾

章节 核心问题 解决方案
第一章 如何让每个页面自动包裹布局组件? 编译时注入 vite-plugin-uni-layout
第二章 如何在运行时读取 pages.json 配置? Vite 虚拟模块 virtual:uni-pages-json
第三章 如何设计可配置的 NavBar 和 TabBar? AppLayout + Layout 组件分层
第四章 页面如何与全局容器通信? useAppLayout + registerLayout

技术架构

API 层
useAppLayout

页面通信入口
registerLayout

事件注册
组件层
AppLayout.vue

业务聚合层
Layout.vue

基础容器
插件层
vite-plugin-uni-layout

编译时注入
vite-plugin-uni-pages-json

虚拟模块
基础设施建设
pages.json

核心价值

维度 价值
开发效率 一次配置/实现,全局生效
可维护性 集中管理,修改一处全站生效
类型安全 完整的 TypeScript 支持
可扩展 基于 Hook 模式,易于扩展新 API
零侵入 业务代码无需感知底层实现
相关推荐
2501_916007471 天前
iOS开发中抓取HTTPS请求的完整解决方法与步骤详解
android·网络协议·ios·小程序·https·uni-app·iphone
00后程序员张1 天前
Windows 下怎么生成 AppStoreInfo.plist?不依赖 Xcode 的方法
ide·macos·ios·小程序·uni-app·iphone·xcode
__zRainy__1 天前
uni-app 全局容器实战系列(二):Vite 虚拟模块
windows·uni-app
__zRainy__1 天前
uni-app 全局容器实战系列(一):全局容器的实现
uni-app·vite
安生生申1 天前
uni-app 连接 JDY-31 蓝牙串口模块实践
c语言·前端·javascript·stm32·单片机·嵌入式硬件·uni-app
小离a_a1 天前
uniapp小程序封装圆环显示比例数据
android·小程序·uni-app
__zRainy__1 天前
uni-app 全局容器实战系列(三):全局 NavBar 和 TabBar 组件设计
uni-app
一颗小青松2 天前
uniapp输入框fixed定位,导致页面顶起解决方案
前端·uni-app
2501_915106322 天前
深入解析无源码iOS加固原理与方案,保护应用安全
android·安全·ios·小程序·uni-app·cocoa·iphone