news 2026/9/12 3:17:25

React Native for OpenHarmony组件开发:从基础组件到通信实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React Native for OpenHarmony组件开发:从基础组件到通信实战

1. 项目系列回顾与组件学习路线

1.1 上一篇跑通了什么,这一篇要解决什么

RN for OpenHarmony(下文简称 RNOH)学到今天,最重要的一个概念就是组件。上一篇我们把环境搭好,项目能跑起来,这一篇就专门来啃组件这块硬骨头。很多朋友环境搭好之后容易卡住:Hello World 能亮,但真到自己写页面,完全不知道从哪下手。我第一遍学的时候也是这样,后来总结的解决办法就一条——先别急着看架构、看原生化实现,先把组件玩明白,剩下的路就顺了。

如果你跟上篇节奏走完,现在手头应该有一个能启动的 RNOH 工程,不管是真机还是模拟器都行。这篇文章里的示例代码,理论上不挑设备,只要工程能跑起来都能复现。内容上我会把常用基础组件的用法过一遍,再花一整节讲组件之间怎么传数据(父传子、子传父),最后分享几个我在 OpenHarmony 上踩得最多的组件坑。

1.2 推荐的基础环境与版本

在正式敲代码之前,先说下我手头的环境,方便你对号入座:

  • OpenHarmony 4.0 Release 及以上版本,真机或模拟器都可以
  • DevEco Studio 4.0 及以上版本,主要用于编译和安装 HAP
  • RN 0.72.x 分支,RNOH 社区目前主力适配的版本
  • 工程里同时维护 react-native 和 react-native-harmony 两个关键依赖

RNOH 的组件写法和 React Native 官方一致,你写的 JSX 最终会通过 RNOH 的组件映射层渲染到 OpenHarmony 原生组件上。也就是说,大部分时候你在页面上看到的 View、Text、Image,底层对应的是 OpenHarmony 的 ArkUI 组件能力。理解这一点很重要:RNOH 不是重新发明了一套组件,而是把 React Native 的组件模型搬到 OpenHarmony 上。所以网上大量 React Native 的组件教程、踩坑贴,只要 API 版本对得上,都能参考,只是最终渲染效果和个别能力会有差异。

2. 拆解组件模型:组件树、Props 与 State

2.1 先理解组件树:页面就是一棵组件树

我刚开始学 RN 的时候,总觉得组件是玄学,后来用了一个特别土但有效的类比:组件就像俄罗斯套娃。整个 App 是最外面的大娃娃,页面是小一点的娃娃,容器组件是再小一点的娃娃,最里面的文本、按钮、图片就是不能继续拆开的最小娃娃。

在 RNOH 里,你写一个页面,本质就是在描述一棵组件树。根节点是 App 组件,往下挂各种页面容器,页面容器里再挂内容组件。组件可以嵌套,可以复用,也能抽成独立文件。比如一个常见的商品卡片,就是一个 View 容器里套了一个 Image 和一个文本区域,文本区域里又挂了几个不同样式的 Text。整个页面由很多这样的卡片拼起来。

看一段最简单的组件树代码:

import React from 'react'; import { View, Text } from 'react-native'; const App = () => { return ( <View style={{ flex: 1, backgroundColor: '#F5F5F5' }}> <Text style={{ fontSize: 20, color: '#333' }}>Hello OpenHarmony</Text> </View> ); }; export default App;

这段代码里 App 返回的是一棵两层结构:View 是父节点,Text 是子节点。RootView 拿到这棵树之后,会做 diff、计算布局,然后把最终结果交给原生层渲染。你现在可以不用管底层细节,只需要养成一个习惯:闭上眼睛想每个页面的时候,先在脑子里把它拆成一棵树,再动手写代码。

2.2 Props:组件对外暴露的只读输入

我们写组件不只是为了把界面画出来,更重要的是让组件能接收外部数据,这样同一个组件在不同页面能展示不同内容。React 的组件接收外界传参的方式就是 Props。

Props 可以类比成函数参数:外部传什么,组件内部就拿到什么。它有几个特点要注意:第一,Props 是只读的,子组件不能去修改自己收到的 Props,只能读取展示;第二,Props 类型理论上可以由外部决定,但在 TypeScript 项目里我们通常会给每个组件定义一个明确的接口(interface);第三,父组件重新渲染时,如果传给子组件的 Props 发生变化,子组件也会跟着重新渲染。

简单示例:

import React from 'react'; import { View, Text } from 'react-native'; interface UserCardProps { name: string; age: number; } const UserCard = ({ name, age }: UserCardProps) => { return ( <View style={{ padding: 12, backgroundColor: '#fff', borderRadius: 8, marginBottom: 8, }} > <Text style={{ fontSize: 18, fontWeight: '600' }}>{name}</Text> <Text style={{ fontSize: 14, color: '#666' }}>{age} 岁</Text> </View> ); }; export default UserCard;

之后在父组件里直接<UserCard name="张三" age={28} />就能复用了。这个组件值不值钱,就看它能不能被不同页面以不同参数反复使用。

2.3 State:组件内部的临时状态

和 Props 配套出现的概念是 State,也就是组件自己维护的临时状态。什么场景需要 State?凡是页面上的内容会随着用户操作变化的地方,基本都需要。比如轮播图当前切到第几张、输入框里现在有没有文字、开关按钮是开还是关,这些都是状态。

State 和 Props 最大的区别在于:State 由组件自己维护和更新,Props 由外部传入且不可修改。但 State 更新后,组件会自动重新渲染,这是它最核心的作用。我之前见过不少人纠结“为什么要用 useState,直接定义一个变量不行吗”,原因很简单:普通变量变了页面不会刷新,只有通过 setState 触发的状态变更,React 才知道要重新渲染组件树中受影响的部分。

import React, { useState } from 'react'; import { View, Text, Button } from 'react-native'; const Counter = () => { const [count, setCount] = useState(0); return ( <View style={{ padding: 20 }}> <Text style={{ fontSize: 32, textAlign: 'center' }}>{count}</Text> <Button title="加一" onPress={() => setCount(count + 1)} /> </View> ); };

点击按钮后,count 加一,Text 里的数字随之更新。整个页面的数据流是单向的:组件持有 State,把 State 通过 Props 传给子组件,子组件触发回调,父组件再更新 State。这个模型贯穿所有 React 系框架,RNOH 也不例外。把这两节吃透,后面写任何复杂页面都不会慌。

3. 常用基础组件实操:从 View 到 FlatList

3.1 View 与 Text:布局和文本的基石

在 RNOH 里,View 的地位类似于网页里的 div,是几乎所有布局的根基。它可以设置宽高、背景色、圆角、边框、阴影,还能通过 flexbox 控制子组件排列。Text 则负责展示文本,支持 fontSize、fontWeight、color、lineHeight 等常规样式。

有个细节我一开始就踩过:RN 和 Web 不一样,Text 默认不会继承外层 View 的文字样式,你必须在 Text 上单独设置字体、字号、颜色。同时,Text 组件里不能随便塞一个 View,Text 的嵌套只能是文本节点或 Text 组件,否则会出现渲染异常或告警。

import React from 'react'; import { View, Text } from 'react-native'; const Card = () => { return ( <View style={{ backgroundColor: '#fff', borderRadius: 10, padding: 16, margin: 12 }}> <Text style={{ fontSize: 20, fontWeight: 'bold', color: '#222' }}>组件卡片标题</Text> <Text style={{ fontSize: 14, color: '#888', marginTop: 6 }}> 这里是组件的描述文字,用来展示基础文本布局效果。 </Text> </View> ); };

View 的样式尽量别全部写在 JSX 里,多了以后很难维护。建议复杂页面用 StyleSheet.create 统一管理,这样还能获得样式表的复用和性能优化。

3.2 Image:本地图片、网络图片与尺寸控制

图片组件在移动端用得非常多。RNOH 的 Image 用法和 RN 官方一致,主要分三种来源:网络图片、本地 require 图片、base64 图片。

网络图片是最常见的,写法是:

<Image style={{ width: 200, height: 150, borderRadius: 8 }} source={{ uri: 'https://example.com/demo.png' }} resizeMode="cover" />

这里有个非常容易踩的坑:在 OpenHarmony 工程里用网络图片,一定要确认 module.json5 里配了网络权限。如果没有 INTERNET 权限,图片会一直加载不出来,控制台报的错还比较隐晦,不是一看就懂的那种。我在自己项目里排查了很久才发现是权限问题。

本地图片方面,RNOH 对 require 的图片资源处理方式和 RN 官方不太一样,如果发现require('./assets/logo.png')在 OpenHarmony 上不显示,先检查工程里资源打包配置是否包含这张图。如果只是写 demo 验证逻辑,最省事的方式是用 base64 字符串作为 uri,或者直接先用网络图把功能跑通,再回去补本地资源配置。

resizeMode 的值有 cover、contain、stretch 等,用法和网页里的 background-size 类似。cover 会裁剪并充满容器,contain 会保持完整比例缩放,stretch 会拉伸填满。实际项目里用得最多的是 cover,尤其是头像、轮播图、封面图。

3.3 TextInput 与 Button:让页面可交互

文本输入框和按钮是绝大多数交互页面的基础。TextInput 是一个受控组件,这意味着它的输入值通常由 State 控制:

import React, { useState } from 'react'; import { View, TextInput, Text, StyleSheet } from 'react-native'; const LoginInput = () => { const [username, setUsername] = useState(''); return ( <View style={styles.container}> <TextInput style={styles.input} placeholder="请输入用户名" placeholderTextColor="#aaa" value={username} onChangeText={setUsername} /> <Text>当前输入:{username}</Text> </View> ); }; const styles = StyleSheet.create({ container: { padding: 20 }, input: { height: 44, borderWidth: 1, borderColor: '#ddd', borderRadius: 8, paddingHorizontal: 12, fontSize: 16, }, });

handleChangeText 会收到用户输入的最新文本,你直接 setState 就行。有个细节:如果界面上有输入框,同时有按钮,建议关注一下键盘弹起后是否会遮挡输入框。RN 官方提供 KeyboardAvoidingView 组件,在 OpenHarmony 上也是可用的,遇到遮挡问题可以给它包一层。

Button 组件是最基础的按钮,只有一个 title 和 onPress 两个主要参数。它的样式在不同平台上差异比较大,想自定义背景色、圆角、文字的按钮,通常需要自己用 TouchableOpacity、Pressable 或 View 包一层实现。对于入门阶段,先记住 Button 是最快的选择,需要样式再自己封组件。

3.4 ScrollView 与 FlatList:滚动容器与长列表

页面内容超出屏幕时需要滚动。RNOH 里有两个常用的滚动容器:ScrollView 和 FlatList。

ScrollView 适合内容数量可控、一次性全部渲染的场景,比如一个详情页、一个表单。它的用法很简单,把要滚动的内容放进去就行:

import React from 'react'; import { ScrollView, View, Text } from 'react-native'; const DetailPage = () => { return ( <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}> <Text>第一段内容</Text> <View style={{ height: 600, backgroundColor: '#f0f0f0', marginVertical: 12 }} /> <Text>第二段内容</Text> </ScrollView> ); };

但如果你要渲染一个长列表,比如几百上千条数据,就千万别用 ScrollView 去 map。这个时候要用 FlatList。FlatList 在 RNOH 里走的是原生列表容器,会做回收复用,性能比 ScrollView 好很多。

import React from 'react'; import { FlatList, View, Text } from 'react-native'; interface Item { id: string; title: string; } const data: Item[] = [ { id: '1', title: '第一项' }, { id: '2', title: '第二项' }, { id: '3', title: '第三项' }, ]; const ListPage = () => { return ( <FlatList data={data} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <View style={{ padding: 12, borderBottomWidth: 1, borderColor: '#eee' }}> <Text>{item.title}</Text> </View> )} /> ); };

keyExtractor 很重要,它让列表项具备稳定的标识。尤其是列表数据会发生增删改的时候,不要依赖数组下标作 key,否则会引发渲染错乱。

4. 组件间通信落地:父传子、子传父与一个小实战

4.1 父传子:通过 Props 把数据传下来

组件通信最常见的方向是父组件向子组件传数据。数据的通道就是我们前面讲的 Props。父组件在子组件标签上写属性,子组件通过 props 形参接收,这个过程是单向的,数据只能向下流。

举个实际场景:一个商品列表页里,每个商品行的 UI 和交互都是一样的,只有内容不同。这时候你抽一个 ProductItem 组件,接收 title、price 等 Props,父组件用 map 循环生成多个 ProductItem 就行。如果未来要改商品卡的样式,只需要维护 ProductItem 一个文件,不用改父组件里的每一处重复代码。

我在写这种组件的时候,尽量会在组件开头声明清楚 Props 类型,比如:

interface ProductItemProps { title: string; price: number; onPress: () => void; }

尤其多人协作的项目里,类型就是用代码写文档,省去很多口头沟通成本。

4.2 子传子:通过回调把事件抛上去

子组件往父组件传数据,方向是相反的,不能再靠 Props 直接传。正确的做法是:父组件通过 Props 把一个回调函数传给子组件,子组件在合适的时机调用这个回调,把数据作为参数传出去。这就是所谓的“回调抛事件”。

举一个加减数量的例子。父组件维护一个 number 类型的 State,把这个值通过 Props 传给子组件显示,同时传给子组件一个 onChange 回调。子组件内部点击加号或减号时,调用 onChange,把计算后的最新值传回给父组件,父组件再 setState 更新。

这种写法也叫“状态提升”,即多个子组件需要共享同一份数据时,把这份数据放到它们共同的父组件里,然后通过 Props 下发、回调上传。

4.3 实战:手写一个自动轮播图组件

轮播图在移动端应用里太常见了,很多新手习惯直接引入第三方库。但我个人建议入门阶段至少手写一个,因为它是理解 Props、State、生命周期清理和 ScrollView 滚动事件的最佳练手项目。

需求很简单:一组数据,横向自动滚动,底部带指示器,用户手动滑动后继续自动轮播。这里我用 ScrollView 的 horizontal + pagingEnabled 实现,加一个定时器控制自动切换。

import React, { useEffect, useRef, useState } from 'react'; import { ScrollView, View, Text, Dimensions, NativeSyntheticEvent, NativeScrollEvent, } from 'react-native'; const { width } = Dimensions.get('window'); interface BannerData { title: string; color: string; } interface BannerProps { data: BannerData[]; interval?: number; } const Banner = ({ data, interval = 3000 }: BannerProps) => { const scrollRef = useRef<ScrollView>(null); const [active, setActive] = useState(0); useEffect(() => { const timer = setInterval(() => { const next = (active + 1) % data.length; scrollRef.current?.scrollTo({ x: next * width, animated: true }); setActive(next); }, interval); return () => clearInterval(timer); }, [active, data.length, interval]); return ( <View> <ScrollView ref={scrollRef} horizontal pagingEnabled showsHorizontalScrollIndicator={false} onMomentumScrollEnd={(e: NativeSyntheticEvent<NativeScrollEvent>) => { const index = Math.round(e.nativeEvent.contentOffset.x / width); setActive(index); }} > {data.map((item, index) => ( <View key={index} style={{ width, height: 150, backgroundColor: item.color, justifyContent: 'center' }} > <Text style={{ textAlign: 'center', color: '#fff', fontSize: 20 }}> {item.title} </Text> </View> ))} </ScrollView> <View style={{ flexDirection: 'row', justifyContent: 'center', marginTop: 8 }}> {data.map((_, index) => ( <View key={index} style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: index === active ? '#333' : '#ccc', marginHorizontal: 3, }} /> ))} </View> </View> ); }; export default Banner;

这个组件有四个关键点:

第一,ScrollView 设了 horizontal 和 pagingEnabled,它的作用是把滚动变成整页切换,一个屏宽一页。第二,定时器用 setInterval 驱动,每隔 interval 毫秒调用 scrollTo 滚到下一张。第三,用户手动滑动结束后,通过 onMomentumScrollEnd 方法计算当前在第几页,更新 active,这样指示器会跟着变,定时器的下一轮也是从当前页继续。第四,useEffect 返回 clearInterval,这一步绝对不能省,否则组件卸载后定时器还在跑,轻则内存泄漏,重则报错。

写完这个组件之后,你对组件通信、State 更新时机、生命周期清理的理解会一下子通透很多。后续如果觉得自研轮播不够用,再去引入社区成熟轮播库,也会更容易看懂它的源码。

5. 踩坑实录:布局、图片、滚动的常见问题排查

5.1 布局不生效与 flexDirection 的默认值

我在 OpenHarmony 上调试的第一个页面就遇到了布局不生效的问题:页面横向排了两个 View,但始终是上下排列。排查了大半天,最后发现是 flexDirection 的默认值在作怪。RN 里 View 的 flexDirection 默认是 column,也就是主轴从上到下,和 Web 端 flex 默认 row 不一样。如果不显式设置,两个子 View 就会纵向排列。

还有个容易混淆的点是 justifyContent 和 alignItems。这两个属性都是控制子组件排列的,但方向不同:justifyContent 控制主轴方向上的排列,alignItems 控制交叉轴方向上的排列。由于主轴默认是垂直的,所以先说 justifyContent 控制的是垂直方向,很多人下意识以为它是水平居中的,写出来效果不对就要懵半天。

排查布局问题的时候,我的习惯是先看父容器有没有确定的宽高或者 flex,再看主轴方向,最后看 justifyContent 和 alignItems 有没有写反。大多数布局问题跑不出这三步。

5.2 尺寸适配与 1px 边框的写法

RNOH 里的长度单位在逻辑上和 Android 的 dp 类似,不是物理像素,所以在高分辨率屏幕上,直接写borderWidth: 1实际上显示出来可能是多个物理像素,视觉上边框会显得过粗。想要真正的 1 物理像素边框,可以这样写:

import { StyleSheet } from 'react-native'; const styles = StyleSheet.create({ divider: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee', }, });

StyleSheet.hairlineWidth 会根据当前设备的像素比自动计算出最细的线宽,在不同设备上展示效果更专业。如果你需要更多适配逻辑,比如根据屏幕宽度动态计算尺寸,可以用 Dimensions.get('window') 拿屏幕宽高,再按比例算。但要注意,Dimensions 拿到的尺寸不代表安全区域,页面底部如果有 Home Indicator,内容可能被遮挡,这时候需要配合 SafeAreaView 或 padding 处理。

5.3 图片加载不出来的几类原因

在 OpenHarmony 上用 Image 加载图片,失败的情况还挺常见的,我整理过一张排查表:

现象常见原因排查顺序
网络图片一直转圈缺少 INTERNET 权限先查 module.json5 权限配置
网络图片瞬间报错URL 不可达或证书问题用浏览器确认地址能直接访问
本地图片不显示require 资源未打包进工程查工程资源打包配置
图片显示但尺寸不对没设置 style 宽高给 Image 设置明确宽高
某些格式不支持原生化解码能力限制换 png、jpg 验证

网络图片的权限问题最容易阴人,因为日志提示不明显,而且有时候看起来像是组件用错了。后来我养成了习惯:新建 RNOH 工程第一件事就是检查网络权限。

5.4 滚动嵌套冲突与长列表卡顿

滚动容器嵌套也是新手重灾区。最常见的场景是外层页面用 ScrollView,里面某一段又套了一个横向 ScrollView,这时候手势处理会打架,表现为横滑总是触发外层竖向滚动,或者反过来。

我先说一个简单的判断标准:能避免嵌套就避免。如果确实需要嵌套,横向滚动和纵向滚动同时存在时,要注意里层的横向 ScrollView 是否抢手势,RNOH 原生层会做手势仲裁,但不是所有情况都完美。测试时重点关注两个方向滑动的流畅度。

长列表卡顿的排查思路更直接:先看是不是用了 ScrollView 渲染大量数据。如果数据量超过几百条,建议直接换 FlatList。FlatList 还支持 initialNumToRender、maxToRenderPerBatch 等配置项,数据量特别大的时候可以按需调整。另外,renderItem 里尽量不要写内联箭头函数,每次渲染都会创建新函数,对长列表的性能会有影响。把 renderItem 抽成组件或稳定的函数,能减少不必要的重渲染。

6. 最后说点实操心得

学了这么多组件,最后分享几条我个人跑项目的经验。

第一,RNOH 的组件体系中,JS 侧 API 和 React Native 官方高度一致,但 OpenHarmony 的原生渲染能力和 Android/iOS 并不是一一对应。遇到一个组件在文档里存在但表现不对,先别急着怀疑自己写错,试着去 RNOH 社区仓库看对应示例,很多答案都在示例工程里。

第二,写组件时尽量保持单一职责。一个组件只做一件事,做复杂了就想办法拆。我见过有人把一个页面全部逻辑塞进一个巨大的组件文件,后期维护真的很难受。抽组件不是为了炫技,是为了下一次改需求的时候,你只需要改一个局部文件,而不是在一千行代码里翻找。

第三,调试组件问题时,打开 DevTools 看告警信息,同时多看原生日志。RNOH 的桥接层会把一些原生错误打到 logcat 里,这些信息往往是 JS 侧看不到的。小组件可以先在 demo 里单独验证,跑通了再集成到业务页面,能省不少排查时间。

组件这块内容确实不难,就是知识点琐碎。我建议你把这篇文章里的示例代码一个个敲一遍,尤其是轮播图组件,花一个小时完整写出来,比看三遍文档都管用。下一篇我打算聊聊 RNOH 里样式的细节,包括 flexbox 布局的常见布局模板和主题化方案。如果这个系列对你有帮助,评论区告诉我你更想先看哪部分内容,我来给你安排。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 3:13:07

ToF相机全链路解析:从光机电硬件到V4L2驱动与点云落地

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

作者头像 李华
网站建设 2026/9/12 3:11:48

技术播客制作全攻略:从设备选型到内容传播

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

作者头像 李华
网站建设 2026/9/12 3:10:07

AES加密模式详解:从ECB到GCM的安全演进与实践

1. 加密模式演进背景与核心需求现代加密算法中&#xff0c;AES&#xff08;Advanced Encryption Standard&#xff09;作为对称加密的黄金标准&#xff0c;其加密模式的选择直接影响着数据安全级别。2001年成为美国联邦标准后&#xff0c;AES逐步取代DES成为行业主流。但很多开…

作者头像 李华
网站建设 2026/9/12 3:09:53

拼多多多多客联盟CPS工具包:解压、API对接与订单佣金同步实战

简介&#xff1a;拼多多多多客联盟 CPS 工具包是一份面向电商推广者、运营人员及 Python 开发者的实用资源&#xff0c;主要解决多多客联盟营销中的推广链接生成、订单跟踪和佣金结算等对接难题。整个压缩包共 47 个文件&#xff0c;体积仅 63KB&#xff0c;结构以 DDK_SDK-mas…

作者头像 李华
网站建设 2026/9/12 3:08:30

ReVanced Manager 打补丁失败或补丁后应用崩溃怎么排查

ReVanced Manager 打补丁失败或补丁后应用崩溃怎么排查 【免费下载链接】revanced-manager &#x1f48a; Application to use ReVanced on Android 项目地址: https://gitcode.com/GitHub_Trending/re/revanced-manager 用 ReVanced Manager 给 Android 应用打补丁时&…

作者头像 李华
网站建设 2026/9/12 3:05:46

音频分离零门槛上手:用 UVR 3 步把歌里的人声提取出来

音频分离零门槛上手&#xff1a;用 UVR 3 步把歌里的人声提取出来 【免费下载链接】ultimatevocalremovergui GUI for a Vocal Remover that uses Deep Neural Networks. 项目地址: https://gitcode.com/GitHub_Trending/ul/ultimatevocalremovergui 想做卡拉OK伴奏&am…

作者头像 李华