鸿蒙 KMP 跨平台实战:Bilibili 应用数据层复用与 NAPI 桥接

发布时间:2026/10/20 17:46:14

鸿蒙 KMP 跨平台实战:Bilibili 应用数据层复用与 NAPI 桥接 简介这份PDF资料面向已熟悉Kotlin、具备一定鸿蒙开发经验的研发人员聚焦Kotlin Multiplatform在HarmonyOS上的跨平台落地难题以鸿蒙版Bilibili开发实践为主线讲解如何让业务逻辑在鸿蒙、Android与iOS之间复用从而降低多端开发成本。资源包仅1个PDF文件约143KB篇幅紧凑但技术密度较高适合作为KMP与鸿蒙集成的案头参考。内容覆盖工具链准备DevEco Studio、KMP插件、Karakum、公共模块与Android/iOS/鸿蒙模块的项目结构划分并重点拆解将鸿蒙.d.ts声明转换为Kotlin声明、编写共享业务逻辑、通过expect/actual实现平台特定代码、以JsExport导出并集成到鸿蒙工程等核心步骤。针对无法直接调试Kotlin代码、三方库兼容性冲突、编译产物体积过大等常见痛点文中也给出了断点调试、API替换与tree-shaking等排错与优化思路并附完整代码示例。目前已有692人学习适合希望系统掌握KMP跨端方案并动手实践的开发者参考。1. 鸿蒙上的 KMP 跨平台为什么值得把 Bilibili 这类应用当试验田鸿蒙应用开发走到 2025 年纯 ArkTS 写业务已经能跑通大部分场景但一旦涉及多端复用团队就会面临一个很现实的问题同一套业务逻辑Android 写一遍、iOS 写一遍、鸿蒙再写一遍维护成本直接翻三倍。Kotlin MultiplatformKMP给出的思路是把数据层、网络层、领域逻辑抽到 commonMain 里各平台只写 UI 和平台能力适配。拿鸿蒙版 Bilibili 这类视频应用做实践是因为它同时踩中了 KMP 最擅长的部分网络请求、JSON 解析、数据缓存、状态管理和最不擅长的部分播放器、弹幕渲染、系统分享边界清晰适合验证 KMP 在鸿蒙生态里的真实可用度。这篇文章面向的是已经会写 ArkTS、想评估 KMP 能不能进生产环境的鸿蒙开发者以及正在做多端架构选型的技术负责人。2. 把 KMP 工程接进鸿蒙从 Gradle 配置到第一个 commonMain 函数2.1 为什么选 KMP 而不是纯 ArkTS 或 Flutter纯 ArkTS 的问题在于复用天花板低鸿蒙和 Android 之间只能靠手动同步代码。Flutter 在鸿蒙上的适配依赖社区维护的引擎分支版本跟进有延迟且 Dart 生态和现有 Kotlin 后端服务对不齐。KMP 的优势是 Kotlin 本身和 Android 同源鸿蒙侧通过 Kotlin/Native 编译成静态库或动态库再用 ArkTS 的 NAPI 机制调用。代价是构建链路变长调试不能像纯 ArkTS 那样直接断点进 Kotlin 代码需要靠日志和单元测试兜底。选型时我一般看三个指标commonMain 能覆盖多少业务代码、平台特定实现有多少、构建产物在鸿蒙设备上的启动耗时。以 Bilibili 类应用为例视频列表分页、用户信息、评论树、搜索建议这些纯数据逻辑可以做到 80% 以上进 commonMain播放器控制、弹幕引擎、推送接收必须留在 ArkTS 侧。2.2 工程结构shared 模块怎么切一个能落地的 KMP 工程通常长这样project-root/ shared/ build.gradle.kts src/ commonMain/kotlin/ # 业务逻辑、数据模型、网络层 androidMain/kotlin/ # Android 平台 actual 实现 iosMain/kotlin/ # iOS 平台 actual 实现 ohosMain/kotlin/ # 鸿蒙平台 actual 实现 ohos-app/ # ArkTS 宿主工程 android-app/ ios-app/ohosMain 是鸿蒙侧的平台源集里面放 expect/actual 的 actual 实现比如文件路径获取、网络引擎初始化、日志输出。这个目录名不是固定的但社区里用 ohosMain 比较统一方便和 HarmonyOS 的构建插件对接。2.3 Gradle 配置Kotlin/Native 目标与鸿蒙产物shared 模块的 build.gradle.kts 核心配置如下plugins { kotlin(multiplatform) version 2.0.20 kotlin(plugin.serialization) version 2.0.20 } kotlin { // Android 目标 androidTarget() // iOS 目标 iosX64(); iosArm64(); iosSimulatorArm64() // 鸿蒙目标通过 Kotlin/Native 编译为共享库 // 注意鸿蒙目前没有官方 KMP target // 常见做法是编译成 linuxArm64 或 ohosArm64 自定义 target linuxArm64 { binaries { sharedLib { baseName shared } } } sourceSets { val commonMain by getting { dependencies { implementation(io.ktor:ktor-client-core:2.3.12) implementation(io.ktor:ktor-client-content-negotiation:2.3.12) implementation(io.ktor:ktor-serialization-kotlinx-json:2.3.12) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1) implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1) } } val commonTest by getting { dependencies { implementation(kotlin(test)) } } val ohosMain by creating { dependsOn(commonMain) dependencies { implementation(io.ktor:ktor-client-okhttp:2.3.12) } } } }这段配置的关键点KMP 官方没有 ohos target所以鸿蒙侧通常走 linuxArm64 交叉编译或者用社区维护的 ohos 插件。编译产物是 .so 共享库放到 ArkTS 工程的 libs 目录下通过 NAPI 暴露接口。参数上baseName 决定产物文件名sharedLib 表示动态库如果要做静态链接就换成 staticLib。Ktor 的引擎在 ohosMain 里选 OkHttp 是因为鸿蒙底层兼容 POSIX 网络接口OkHttp 的 Kotlin/Native 版本能跑通但要注意 DNS 解析在部分鸿蒙设备上需要手动指定。2.4 第一个 commonMain 函数Bilibili 视频列表请求在 commonMain 里定义一个视频列表接口// commonMain/kotlin/data/VideoRepository.kt package data import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json Serializable data class VideoItem( val aid: Long, val title: String, val cover: String, val duration: Int, val upName: String ) Serializable data class VideoListResponse( val code: Int, val message: String, val data: ListVideoItem ) class VideoRepository(private val client: HttpClient) { suspend fun fetchRecommend(page: Int, pageSize: Int 20): ListVideoItem { val response: VideoListResponse client.get(https://api.example.com/video/recommend) { parameter(page, page) parameter(pageSize, pageSize) }.body() if (response.code ! 0) { throw IllegalStateException(API error: ${response.message}) } return response.data } } // 工厂函数各平台传入不同引擎 fun createHttpClient(engine: HttpClientEngine): HttpClient { return HttpClient(engine) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys true isLenient true }) } } }逻辑说明VideoRepository 只依赖 HttpClient 抽象不关心底层是 OkHttp 还是 Darwin。createHttpClient 接收平台传入的引擎这样 Android 传 OkHttp、iOS 传 Darwin、鸿蒙传 OkHttp 的 Native 版本。参数上ignoreUnknownKeys 必须开因为 Bilibili 类接口经常加字段不开会导致反序列化直接崩。isLenient 处理一些非标准 JSON 格式。2.5 鸿蒙侧 actual 实现与 NAPI 桥接在 ohosMain 里提供 actual 实现// ohosMain/kotlin/platform/HttpEngine.ohos.kt package platform import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp actual fun platformHttpEngine(): HttpClientEngine OkHttp.create()然后在 ArkTS 侧通过 NAPI 调用编译好的 .so// ohos-app/src/main/cpp/napi_init.cpp #include napi/native_api.h #include string // 假设 shared 库导出了 fetchRecommend 的 C 接口 extern C const char* fetch_recommend(int page, int pageSize); static napi_value FetchRecommend(napi_env env, napi_callback_info info) { size_t argc 2; napi_value args[2]; napi_get_cb_info(env, info, argc, args, nullptr, nullptr); int page, pageSize; napi_get_value_int32(env, args[0], page); napi_get_value_int32(env, args[1], pageSize); const char* result fetch_recommend(page, pageSize); napi_value jsonResult; napi_create_string_utf8(env, result, NAPI_AUTO_LENGTH, jsonResult); return jsonResult; }这一步是整个链路里最容易翻车的地方。Kotlin/Native 导出的 C 接口需要加 CName 注解字符串要手动管理内存ArkTS 侧拿到的 JSON 字符串还要再解析一次。我一般会在 Kotlin 侧直接返回 JSON 字符串ArkTS 用 JSON.parse 处理避免在 C 层做复杂对象映射。3. 数据层与状态管理commonMain 里怎么组织 Bilibili 的业务逻辑3.1 用 Repository UseCase 分层隔离平台差异commonMain 里的业务逻辑要经得起三端复用分层必须清晰。我一般分三层Repository 负责数据来源网络、缓存、数据库UseCase 负责业务规则比如“先读缓存再请求网络网络失败返回缓存”ViewModel 负责状态暴露。ViewModel 这层在 KMP 里比较特殊因为 Android 有 Jetpack ViewModel、iOS 有 ObservableObject、鸿蒙有 ArkTS 的 State所以 commonMain 里只定义状态数据类和状态流转逻辑不继承任何平台基类。// commonMain/kotlin/domain/VideoListUseCase.kt package domain import data.VideoItem import data.VideoRepository import kotlinx.coroutines.flow.* class VideoListUseCase( private val repository: VideoRepository, private val cache: VideoCache ) { private val _state MutableStateFlow(VideoListState()) val state: StateFlowVideoListState _state.asStateFlow() suspend fun loadFirstPage() { _state.update { it.copy(isLoading true, error null) } try { val cached cache.getRecommend() if (cached.isNotEmpty()) { _state.update { it.copy(videos cached, isLoading false) } } val remote repository.fetchRecommend(page 1) cache.saveRecommend(remote) _state.update { it.copy(videos remote, isLoading false) } } catch (e: Exception) { _state.update { it.copy(isLoading false, error e.message) } } } } data class VideoListState( val videos: ListVideoItem emptyList(), val isLoading: Boolean false, val error: String? null )参数说明StateFlow 是冷流转热流适合做 UI 状态。cache 是 expect/actual 接口Android 用 Room、iOS 用 SQLite、鸿蒙用关系型数据库。loadFirstPage 里先读缓存再请求网络这是视频类应用的标准做法能显著降低首屏白屏时间。3.2 鸿蒙侧状态订阅ArkTS 怎么消费 Kotlin FlowKotlin 的 Flow 不能直接给 ArkTS 用需要在 ohosMain 里包一层回调接口// ohosMain/kotlin/bridge/StateBridge.kt package bridge import domain.VideoListState import kotlinx.coroutines.* import kotlinx.coroutines.flow.* class StateBridge(private val scope: CoroutineScope) { fun observeVideoList( useCase: VideoListUseCase, onState: (VideoListState) - Unit ): Job { return useCase.state .onEach { onState(it) } .launchIn(scope) } }ArkTS 侧通过 NAPI 注册回调函数Kotlin 侧每次状态变更就调一次。这里要注意线程切换Kotlin/Native 的协程默认跑在后台线程回调到 ArkTS 必须切回主线程否则 UI 更新会出问题。常见做法是在 onState 回调里用 Dispatchers.Main 包一层鸿蒙的 Main 调度器需要自己实现因为 Kotlin/Native 没有内置鸿蒙的主线程 Dispatcher。3.3 缓存策略内存 磁盘两级缓存视频列表的缓存我一般做两级内存缓存用 LruCache 思路磁盘缓存用 SQLite 或文件。commonMain 里定义接口// commonMain/kotlin/data/VideoCache.kt package data expect class VideoCache { suspend fun getRecommend(): ListVideoItem suspend fun saveRecommend(items: ListVideoItem) suspend fun clearExpired(maxAgeMs: Long) }ohosMain 的实现用鸿蒙的关系型数据库// ohosMain/kotlin/data/VideoCache.ohos.kt package data import ohos.data.relationalStore.* import ohos.app.Context actual class VideoCache(private val context: Context) { private val store: RdbStore by lazy { val config StoreConfig( SecurityLevel.S1, video_cache.db, null ) RdbStore(context, config) } actual suspend fun getRecommend(): ListVideoItem { val predicates RdbPredicates(recommend) predicates.orderByDesc(cached_at) predicates.limitAs(50) val resultSet store.query(predicates, arrayOf(aid, title, cover, duration, up_name)) val list mutableListOfVideoItem() while (resultSet.goToNextRow()) { list.add( VideoItem( aid resultSet.getLong(resultSet.getColumnIndex(aid)), title resultSet.getString(resultSet.getColumnIndex(title)), cover resultSet.getString(resultSet.getColumnIndex(cover)), duration resultSet.getInt(resultSet.getColumnIndex(duration)), upName resultSet.getString(resultSet.getColumnIndex(up_name)) ) ) } resultSet.close() return list } actual suspend fun saveRecommend(items: ListVideoItem) { store.beginTransaction() try { items.forEach { item - val values ValuesBucket().apply { putLong(aid, item.aid) putString(title, item.title) putString(cover, item.cover) putInt(duration, item.duration) putString(up_name, item.upName) putLong(cached_at, System.currentTimeMillis()) } store.insert(recommend, values, ConflictResolution.ON_CONFLICT_REPLACE) } store.commit() } catch (e: Exception) { store.rollBack() throw e } } actual suspend fun clearExpired(maxAgeMs: Long) { val expireTime System.currentTimeMillis() - maxAgeMs val predicates RdbPredicates(recommend) predicates.lessThan(cached_at, expireTime) store.delete(predicates) } }参数说明SecurityLevel.S1 是鸿蒙数据库的安全等级视频列表这种非敏感数据用 S1 就够。ON_CONFLICT_REPLACE 保证 aid 重复时覆盖旧记录。clearExpired 的 maxAgeMs 一般设 24 小时视频推荐列表更新频率高缓存太久反而影响体验。4. 避坑与排查KMP 接鸿蒙时最容易翻车的五个点4.1 坑一Kotlin/Native 编译产物在鸿蒙设备上加载失败现象ArkTS 侧调用 NAPI 接口时报 “module not found” 或直接闪退日志里看到 dlopen 失败。原因Kotlin/Native 编译出的 .so 依赖 libc 版本和鸿蒙设备上的不一致或者产物架构不对。鸿蒙设备主流是 arm64但模拟器可能是 x86_64如果只编了 linuxArm64 就会在模拟器上挂。解决在 build.gradle.kts 里同时编 linuxX64 和 linuxArm64产物按架构分目录存放。ArkTS 侧加载时根据设备架构选对应目录。另外用readelf -d shared.so检查依赖库缺什么补什么。4.2 坑二协程在鸿蒙主线程调度失效现象Kotlin 侧回调更新状态后ArkTS 的 UI 不刷新或者报 “not on UI thread”。原因Kotlin/Native 没有鸿蒙的主线程 Dispatcher默认跑在后台线程ArkTS 的 State 必须在主线程更新。解决在 ohosMain 里实现一个 MainDispatcher用鸿蒙的 EventHandler 或 TaskDispatcher 把回调 post 到主线程。代码大致是// ohosMain/kotlin/platform/MainDispatcher.kt package platform import kotlinx.coroutines.* import ohos.eventhandler.EventHandler import ohos.eventhandler.EventRunner actual fun mainDispatcher(): CoroutineDispatcher object : CoroutineDispatcher() { private val handler EventHandler(EventRunner.getMainEventRunner()) override fun dispatch(context: CoroutineContext, block: Runnable) { handler.postTask(block) } }然后在 commonMain 里用withContext(mainDispatcher())包住 UI 回调。4.3 坑三JSON 反序列化在鸿蒙上遇到字段类型不一致现象同样的接口Android 上解析正常鸿蒙上抛 SerializationException。原因Kotlin/Native 的 kotlinx-serialization 对数字类型更严格接口返回的 duration 有时是 Int 有时是 StringAndroid 的 OkHttp 引擎可能做了隐式转换Native 引擎不会。解决在 Json 配置里加coerceInputValues true或者把 duration 定义成 JsonElement 再手动转。更稳妥的做法是给每个字段加 SerialName 并写自定义 serializer。4.4 坑四内存泄漏——Kotlin 对象被 ArkTS 长期持有现象页面退出后内存不释放反复进出页面内存持续增长。原因NAPI 回调注册后没有反注册Kotlin 侧的 StateBridge 持有 useCaseuseCase 持有 repositoryrepository 持有 HttpClient整条链被 ArkTS 的全局回调引用。解决在 ArkTS 页面的 aboutToDisappear 里调用反注册接口Kotlin 侧 cancel 对应的 Job。StateBridge 的 observeVideoList 返回 JobArkTS 侧保存这个 Job 的句柄页面销毁时调 cancel。4.5 坑五构建缓存导致 commonMain 改动不生效现象改了 commonMain 的代码重新编译后鸿蒙侧行为没变。原因Kotlin/Native 的编译缓存和 Gradle 缓存叠加增量编译有时不识别 commonMain 的变更。解决执行./gradlew clean后重新编译或者在 gradle.properties 里关掉 Kotlin/Native 的增量编译kotlin.native.cacheKindnone。这个开关会拖慢构建速度只在排查阶段用。5. 进阶技巧用 KMP 的 expect/actual 把播放器控制也抽象出来播放器是视频应用里最平台相关的部分但它的控制接口其实可以抽象。commonMain 里定义// commonMain/kotlin/player/VideoPlayer.kt package player expect class VideoPlayer { fun setDataSource(url: String) fun prepare() fun play() fun pause() fun seekTo(positionMs: Long) fun release() val currentPosition: Long val duration: Long val isPlaying: Boolean }鸿蒙侧的 actual 实现包装 ArkTS 的 AVPlayer// ohosMain/kotlin/player/VideoPlayer.ohos.kt package player import ohos.media.player.AVPlayer import ohos.media.common.Source actual class VideoPlayer { private var player: AVPlayer? null actual fun setDataSource(url: String) { player AVPlayer().apply { setSource(Source(url)) } } actual fun prepare() { player?.prepare() } actual fun play() { player?.play() } actual fun pause() { player?.pause() } actual fun seekTo(positionMs: Long) { player?.seekTo(positionMs.toInt()) } actual fun release() { player?.release() player null } actual val currentPosition: Long get() player?.currentTime?.toLong() ?: 0L actual val duration: Long get() player?.duration?.toLong() ?: 0L actual val isPlaying: Boolean get() player?.isPlaying ?: false }这样 commonMain 里的播放逻辑比如“播放到 80% 时预加载下一集”就能复用。但要注意播放器的状态回调onPrepared、onCompletion在 KMP 里需要用回调接口桥接ArkTS 侧注册监听Kotlin 侧转发到 commonMain 的 Flow。验证 KMP 方案是否值得投入我一般跑一个最小闭环commonMain 写一个带分页和缓存的列表逻辑鸿蒙侧只写 UI 和播放器看开发耗时和纯 ArkTS 比是多了还是少了。如果 commonMain 覆盖率能到 60% 以上且构建链路稳定就值得继续。我自己的习惯是每接一个新平台先花半天把 NAPI 桥接和主线程调度跑通这两个通了后面就是填业务代码。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/20 17:46:14

安全专用大模型SecGPT vs 通用模型Qwen2.5:网络安全分析实战对比

我最初接触这个选题,是因为帮某团队做安全告警降噪时,发现他们已经在用通用大模型分析日志,效果却一言难尽——模型很能聊,但聊出来的结论经常把安全工程师带沟里。后来我把 SecGPT 这类安全专用大模型和通用模型放到同一批真实任…

2026/10/20 17:41:13

物理规律导向视频模型:技术路线、实操要点与常见问题排查

1. 物理规律导向视频模型到底在解决什么问题 视频生成模型这两年迭代速度极快,从最早的几秒模糊片段,到现在能出十几秒甚至更长的连贯画面,进步肉眼可见。但如果你真正拿这些模型做过内容生产,就会发现一个特别尴尬的现象&#xf…

2026/10/20 18:52:03

从LangChain到LangGraph:Agent工作流编排与状态管理实战

去年年中,我的团队决定把几个稳定的业务Agent全部从LangChain迁到LangGraph。当时不少人觉得这是折腾——毕竟LangChain用得好好的,Agent也能跑。结果迁移完第一个复杂Agent之后,所有人的态度都变了:原来那种靠“链”硬拗出来的流…

2026/10/20 18:52:03

哈希表从原理到实现:链地址法、开放寻址法与C++代码详解

哈希表可以说是C进阶路上绕不开的一块硬骨头。很多人在刷题时用过unordered_map,但一问到它底层的“哈希桶结构”到底是什么、开放寻址法和链地址法有什么区别、扩容的时候发生了什么,就说不清楚了。这篇内容我想从原理到底层实现,把哈希表的…

2026/10/20 18:52:03

JVM内存模型与垃圾回收实战解剖:从原理到调优

1. 这不是笔记,是JVM内存运行的“人体解剖图”你有没有过这种体验:代码写得飞起,一上线就OOM;GC日志满屏飘红,却像看天书;调优时改了-XX:UseG1GC,结果吞吐量不升反降,连自己写的对象…

2026/10/20 18:52:03

大模型架构面试必问:GPT/LLaMA/DeepSeek/Qwen核心差异与设计取舍

如果你正打算在2026年冲刺大模型方向的岗位,不管是算法岗、推理优化岗还是Agent应用岗,架构题几乎都是绕不开的必考题。我过去一年带过不少模拟面试,发现一个特别普遍的现象:简历上的项目都写得挺漂亮,一旦被追问到“G…

2026/10/20 18:52:03

JVM内存布局与垃圾回收实战:从原理到参数调优

1. 这不是一份“笔记”,而是一套可落地的JVM内存治理实战手册你点开这个标题,大概率正被三件事反复折磨:线上服务突然卡顿、GC日志里满屏的Full GC警告、还有堆内存使用率曲线像坐过山车一样冲到98%又猛掉——这时候翻官方文档?光…

2026/10/20 18:47:01

开源AI+低代码:一句话生成可运行业务应用

1. 项目概述:当AI不再只是聊天框,而是你手边的业务搭建员“一句话生成业务应用”——这八个字最近在技术圈里反复刷屏,不是营销话术,也不是概念炒作,而是真实发生在某高校实验室和几家中小企业的日常开发现场。我上个月…

2026/10/19 18:06:35

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/20 1:05:37

单片机C运行时从启动到多任务:中断安全与共享资源冲突

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

GCC编译四步详解:预处理、编译、汇编、链接实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

FIW线是什么?FEP注塑线与普通氟塑料线的区别及应用指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑