
基于HarmonyOS_NEXT的仿微信聊天原生应用开发笔记
涉及调用大模型接口实现机器人自动回复、音视频处理、发语音发相册图片、语音文字互转、相机拍照录像、发送位置、扫描二维码、生成二维码等一些能力。
涉及调用大模型接口实现机器人自动回复、音视频处理、发语音发相册图片、语音文字互转、相机拍照录像、发送位置、扫描二维码、生成二维码等一些能力,开发笔记,大佬勿喷。
1. 创建一个WeTalk项目
新建项目:
将讲义中的图片添加到resources/base/media目录下
- 拷贝所有图片
- 导入所有基本色值到color.json中
{
"color": [
{
"name": "start_window_background",
"value": "#FFFFFF"
},
{
"name": "primary",
"value": "#1AAD19"
},
{
"name": "second_primary",
"value": "#a9ea7a"
},
{
"name": "text_primary",
"value": "#2A2929"
},
{
"name": "text_second",
"value": "#818181"
},
{
"name": "back_color",
"value": "#ededed"
},
{
"name": "second_back_color",
"value": "#f6f6f6"
},
{
"name": "border_color",
"value": "#f4f5f6"
},
{
"name": "danger",
"value": "#e75f58"
},
{
"name": "white",
"value": "#ffffff"
},
{
"name": "bottom_color",
"value": "#a4a4a4"
},
{
"name": "bottom_voice_color",
"value": "#515151"
},
{
"name": "voice_back_color",
"value": "#CC3E3E3E"
},
{
"name": "voice_round_color",
"value": "#323232"
},
{
"name": "voice_round_font_color",
"value": "#919191"
},
{
"name": "chat_primary",
"value": "#8aec71"
},
{
"name": "animate_voice_color",
"value": "#4a8040"
},
{
"name": "black",
"value": "#000000"
},
{
"name": "popup_back",
"value": "#4d4d4d"
},
{
"name": "location_back",
"value": "#80767676"
},
{
"name": "pay_back",
"value": "#57ab70"
}
]
}
- 修改项目的名称
如果你也想修改英文环境下的,可以同时修改
- 修改图标
- 使用git管理
建立一个仓库,使用git管理该项目
- 在项目目录下执行
在gitee新建仓库
复制地址
提交一次
然后推送,结束。
-2024.8.15
2. 搭建基础骨架页面
首先实现主页中四个基本的tab组件,并且能够实现切换时样式的变化
- 在ets/models目录下新建一个tab.ets文件
- 入口文件 pages/Index.ets
实现在哪个tab,哪个就变绿色。
3. 联系人数据渲染
- 在models下新建users.ets,分别定义用户类型和10个随机数据
// 用户信息
export interface UserInfo {
username: string // 用户昵称
avatar: string // 用户头像
user_id: string // 用户id
}
export const DefaultUserList: UserInfo[] = [{
username: '小趴菜',
avatar: 'https://img2.baidu.com/it/u=2778471297,524433918&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
user_id: '1'
},{
username: '老板',
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2F9853de1a-3985-42e6-be59-849853318793%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1708069489&t=f960a72e50a399ddec92b18b3c7fc2d9',
user_id: '2'
},{
username: '老婆',
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2Fd064be90-6b8c-4a6d-9721-837206fbb4a7%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1708069489&t=c810057a56c27b5747e1e92bfde37799',
user_id: '3'
},{
username: '物业小张',
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2Fabf9e746-a09c-4b08-bcba-1e347a370226%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1708069489&t=b47c00cad15f8dbdc390e82b95348ed2',
user_id: '4'
},{
username: '水若寒宇',
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2Ff1d11ab1-35ff-4f2c-b9e9-3e0c996d34a2%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1708069489&t=e3234db982e8a36639d170fc5cec7848',
user_id: '5'
},{
username: '小林',
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2Fe9543d7c-02f3-484f-a3f0-5bfa8b2e6ef9%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1708069502&t=118303a29ce4a8ef2c1a496ae880b76b',
user_id: '6'
},{
username: '花开富贵',
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2F8d90d041-784a-407f-a82e-b851fafdf746%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1708069547&t=d374ebb6973f68b95ba345827a304455',
user_id: '7'
},{
username: '妈妈',
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2F373c6d24-1f8b-4000-8dd9-8d8410c35e71%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1708069618&t=51805a82420593c2a3cda47b9f65a80e',
user_id: '8'
},{
username: '沧海一生笑',
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2Fbb560b27-a0b7-4062-ae40-efe4e5a8a748%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1708069619&t=8605f8477712cf9c5a1159db0cd7dab4',
user_id: '9'
},{
username: '爱哭的燕子🐨🐨',
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fsafe-img.xhscdn.com%2Fbw1%2Fdc2617f6-4a68-4d4a-8c54-8cbc84e3446a%3FimageView2%2F2%2Fw%2F1080%2Fformat%2Fjpg&refer=http%3A%2F%2Fsafe-img.xhscdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1708069619&t=a8b7b4f1e600d53e5547479d947b1809',
user_id: '10'
}]
新建一个首选项仓库和工具集:
变量:来维护key
pages下建组件:从首选项获取联系人并渲染
//联系人组件:
import { UserInfoModel } from '../../models/users'
import { WeChatStore } from '../../utils/chat_store'
@Component
struct Connect {
@State
userList:UserInfoModel[]=[]//userList用来接收联系人数组
aboutToAppear(): void {//进入connect就渲染,只渲染一次
this.getUserList()
}
//获取联系人方法:
getUserList(){
this.userList = WeChatStore.getWeChatConnect()//返回联系人
}
//渲染联系人list
build() {
Column(){
Row(){
Search({placeholder:'搜索'})
.height(30)
.borderRadius(4)
.backgroundColor($r("app.color.white"))
}
.width('100%')
.padding({left:10,right:10})
List(){
ForEach(this.userList,(item:UserInfoModel)=>{ //接收uselist传来的UserInfoModel格式的item
ListItem(){
Row({space:10}){//图片和名字的距离
Image(item.avatar)
.width(30)
.height(30)
.borderRadius(4)
Text(item.username)
.fontSize(14)
.fontColor($r("app.color.text_primary"))
}
.height(60)
.width('100%')
.padding({ left:10,right:10 })
}
.stateStyles({
pressed:{//按压的时候变灰色
.backgroundColor($r("app.color.back_color"))
},
normal:{//正常态颜色
.backgroundColor($r("app.color.white"))
}
})
})
}
.divider({
strokeWidth:1,color:$r("app.color.border_color")
})//分隔线
.layoutWeight(1)
.backgroundColor($r("app.color.white"))
}
.width('100%')
.height('100%')
.backgroundColor($r("app.color.back_color"))
}
}
export default Connect
头像是网图,别忘了打开网络权限:
-2024.8.16
4. 联系人筛选
- 定义一个筛选字段和筛选后的数组
- 绑定搜索输入框
- 实现更新方法
效果:
我的模拟器打不了中文,fuck。
5. 新建聊天详情页面
当点击联系人里面的任何一个人的时候,我们需要进入和当前人的聊天详情-聊天详情应该是个页面
- 新建pages/ChatDetail/ChatDetail.ets文件
聊天页面组件:
//聊天页面组件
import { router } from '@kit.ArkUI'
@Entry
@Component
struct ChatDetail {
build() {
Column(){
Stack({alignContent:Alignment.Start}){//开始居左
Image($r("app.media.ic_public_arrow_left"))
.width(30)
.height(30)
.zIndex(2)
.onClick(()=>{
router.back()
})
Text("小果果")
.fontColor($r("app.color.text_primary"))
.width('100%')
.textAlign(TextAlign.Center)
}
.padding({left:10,right:10})
.height(50)
.width('100%')
}
}
}
6. 建立默认用户
建立默认用户的数据类型结构
在常量中定义一个key
在首选项中定义一个获取当前用户的方法
在ability启动之后获取当前用户并设置给全局状态
7. 点击联系人传入通信用户
把当前联系人传到聊天详情页
聊天详情页面接收
8. 封装底部输入框组件
建一个组件- ChatDetail/Components/BottomInput.ets
放到ChatDetail里
效果:
9. 实现键盘避让模式
在整个微信聊天项目中,我们都需要让键盘弹起时,能够将页面进行压缩
- 在ability初始化中实现键盘避让
10. 切换输入模式
- ChatDetail/Component/BottomInput.ets 组件中进行语音和文字发送的切换
当点击左侧喇叭图标时,切换到语音模式,反之切换回来
- 定义一个状态控制,根据状态控制发送语音的按钮和输入框
效果:
11. 消息对象的创建
不论是文本-语音-还是照片-视频,都需要消息对象类型
- 新建models/message.ets文件,创建关于消息的类型
- 创建一个消息类型的枚举
- 利用i2c来生成对应的class类型
i2c .\message.ets
这里的id我们特殊处理下,当没有传入id时,直接在构造函数中自动生成ID
发送时间也处理下,没有传入的情况下 用当前时间
12. 创建消息组件
在消息详情中,我们需要展示一条条的消息,此时我们新建一个Message.ets,用于展示消息内容
- 新建ChatDetail/Components/Message.ets组件
//消息组件:给ChatDetail用的
import { WeChat_ConnectKey, WeChat_CurrentUserKey } from '../../../constant'
import { MessageInfo, MessageInfoModel } from '../../../models/message'
import { UserInfoModel,UserInfo } from '../../../models/users'
@Component
struct Message {
@StorageProp(WeChat_CurrentUserKey)//拿到全局变量默认登录用户user给currentUser
currentUser:UserInfoModel = new UserInfoModel({} as UserInfo)//我
@Prop//当前消息的内容
currentMessage: MessageInfoModel = new MessageInfoModel({}as MessageInfo)
@State//是不是我发的消息?判断这两个iD是否相等
isOwnMessage:boolean = this.currentUser.user_id === this.currentMessage?.sendUser?.user_id
build() {
Row(){
Image("https://img0.baidu.com/it/u=1611182507,2353465472&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=664")
.width(40)
.aspectRatio(1)
.borderRadius(4)
Row(){
Text("你是我爹啊")
.backgroundColor(this.isOwnMessage?$r("app.color.second_primary"):$r("app.color.white"))
.fontColor($r("app.color.text_primary"))
.padding(10)
.margin({left:10,right:10})
.borderRadius(4)
}
.justifyContent(this.isOwnMessage?FlexAlign.End : FlexAlign.Start)//消息靠左还是靠右
.layoutWeight(6)
Text()//没有用。用来分空间的。
.layoutWeight(1)
}
.padding({left:20,right:20})
.direction(this.isOwnMessage?Direction.Rtl : Direction.Ltr)//row的方向
}
}
export default Message
效果:
13. 输入消息后将作者的消息更新到消息列表
- 双向绑定输入框内容
- 输入内容点击发送时,将信息传入到父组件
- 父组件拿到消息添加到当前列表中
- 双向绑定输入框内容-ChatDetail/Components/BottomInput.ets
单击回车触发submit,将消息内容content传出去,通过sendTextMessage传给ChatDetail
ChatDetail接收,给自己的sendTextMessage
sendTextMessage接收形成messLIst消息列表,然后在下面的循环里调用把每一项传给消息组件Message
message接收到之后进行对比是不是我发的并且渲染:
每次发消息都需要点击输入框,比较麻烦,这里讲一个关于输入框聚焦的技巧
1.进页面默认自动聚焦
2.发送完消息自动聚焦
- 进页面默认自动聚焦
通过focusable()设置元素是否可以聚焦
通过defaultFocus()开启默认聚焦
- 发送完消息自动聚焦
通过.id('id')给元素添加id
通过focusControl.requestFocus('id')控制元素聚焦
效果:
14. 封装AI回复的请求接口
由于我们需要对话,我们可以利用一个机器人聊天的接口来获取对应的数据响应
接口地址:https://api.sizhi.com/chat?appid=1d126191abec497586482f2f5ca2e706&userid=&spoken=你好吗
- 在models中定义一个chat.ets
- 在utils下新建一个request.ets文件,封装一个获取聊天的结果的方法
- 在发送自己消息后,请求服务器获取响应的消息添加到List中
效果:
当正在响应时,显示对方正在输入
效果:
聊天的逻辑:
1是我们发的消息,2是机器人的消息。
把1push进数组,然后调用启动2的函数,把2push进数组。
机器人大模型AI接口说明:
思知AI大模型登录:思知 - AI知识助手,有问题找思知 (sizhi.com)
创建自己的机器人
获取Appid
可以看文档
15. 添加信息滚动到底部
给List组件绑定一个scroller,每次添加信息之后,滚动到最底部就可以
- 定义一个scroller
绑定给消息list
- 自己or对方发消息,每次添加列表之后,都要滚动到最底部
16. 通过首选项缓存当前的聊天记录
设计存储模型:
将每个人的聊天记录分配一个仓库,每个消息分配一个key,这样保证每条消息可以输入8192个字节
在utils中新建store.ets,封装存储方法用于存储聊天记录
需要封装的方法:
- 获取某个人的仓库
- 添加某个人的一条记录
- 获取某个人的所有信息
- 删除某个人的一条记录
- 删除某个人的所有记录
- 获取所有人的最后一条记录
在constants/index.ets中添加一个key
在chat_store.ets中(利用固定Key + 对话用户id作为标识)创建 获取某个人整个的聊天记录,删除某个人整个的聊天记录,添加某个人的某一条的聊天记录,删除某个人的某一条的聊天记录
//获取我和某个人的整个的聊天记录:
static getWeChatMessage(userId:string){//对话者的ID
//(利用固定Key + 对话用户id作为标识)创建 获取某个人整个的聊天记录,删除某个人整个的聊天记录,添加某个人的某一条的聊天记录,删除某个人的某一条的聊天记录
const store = WeChatStore.getWeChatStore()//拿到仓库
return json.parse(store.getSync( `${WeChat_UserRecordKey}_${userId}` , "[]") as string ) as MessageInfoModel[]//获取所有的我和某个人的聊天记录
}
//删除我和某个人的全部聊天记录
static async delWeChatMessage(userId:string){
const store = WeChatStore.getWeChatStore()//拿到仓库
store.deleteSync(`${WeChat_UserRecordKey}_${userId}`)//删除聊天记录
await store.flush()//写入磁盘
}
//添加一条我和某个人的聊天记录
static async addOneWeChatMessage(userId:string,message:MessageInfoModel){
const store = WeChatStore.getWeChatStore()//拿到仓库
const list = WeChatStore.getWeChatMessage(userId)//拿到所有的聊天记录
list.push(message)//把消息加入list
//写入仓库
store.putSync(`${WeChat_UserRecordKey}_${userId}`,JSON.stringify(list))
await store.flush()//写入磁盘
}
//删除我和某个人的聊天记录
static async delOneWeChatMessage(userId:string,messId:string){
const store = WeChatStore.getWeChatStore()//拿到仓库
const list = WeChatStore.getWeChatMessage(userId)//拿到所有的聊天记录
const index = list.findIndex(item => item.id === messId)//拿到索引
list.splice(index,1)//删除一条记录
//写入仓库
store.putSync(`${WeChat_UserRecordKey}_${userId}`,JSON.stringify(list))
await store.flush()//写入磁盘
}
添加到ChatDetial中
每次发消息都把单条消息存入首选项里
我们每次打开聊天都要有以前的聊天记录
所以要每次打开都加载我和这个人的聊天记录,拿到后给messlist渲染,回滚到底部。
这里代码较多,测试首选项的时候记得清空垃圾数据,避免数据污染影响代码结果
不要勾选keep application data 每次更新模拟器就会自动清空历史数据
-2024/8/16
17. 在主页建立聊天记录
因为我们和某个人聊了天,在主页中,应该保留当前的聊天记录,聊天记录中应该是聊天的最后一条记录
只要存在聊天记录,再次进入主页时,应该获取所有人的聊天记录的最后一条
- 在utils/chat_store.ets中声明一个获取所有聊天记录的方法
- 在pages/WeChat/WeChat.ets建立主页的组件(非Page)
初始化时,获取聊天记录
//微操页面组件:
import { MessageInfoModel } from '../../models/message'
import { WeChatStore } from '../../utils/chat_store'
import { router } from '@kit.ArkUI'
import { it } from '@ohos/hypium'
@Component
struct WeChat {
@State//接收最后一条聊天记录的list:
list:MessageInfoModel[] = []
aboutToAppear(): void {
this.getAllRecord
}
//获取聊天记录的最后一条方法:
async getAllRecord(){
this.list = await WeChatStore.getAllLastRecord()
}
//转化时间的方法:将时间戳转化为具体时间,如果是当天的,显示时分,如果不是当天,显示日期
transTime (timeStamp:number){
const sendTime = new Date(timeStamp)//时间戳转化为时间
if (sendTime.getDate() === new Date().getDate()) {
//等于今天,显示时分
return sendTime.getHours().toString().padStart(2,"0")+":"+sendTime.getMinutes().toString().padStart(2,"0")
}
else {//显示月和日
return (sendTime.getMonth() + 1).toString().padStart(2,"0")+"月"+sendTime.getDate().toString().padStart(2,"0")+"日"
}
}
build() {
Column(){
Row(){
Text("微草")
}
.justifyContent(FlexAlign.Center) //微信两个字居中
.width('100%')
.height(50)
List({space:10}){//渲染每个人最后一条消息:
ForEach(this.list,(item:MessageInfoModel)=>{
ListItem(){
Row({space:10}){
Image(item.connectUser.avatar)
.width(50)
.height(50)
.borderRadius(4)
Column(){
Text(item.connectUser.username)
.fontColor($r("app.color.text_primary"))
Text(item.messageContent)
.fontColor($r("app.color.text_second"))
.fontSize(14)
}
.layoutWeight(1)
.height(50)
.justifyContent(FlexAlign.SpaceBetween)
.alignItems(HorizontalAlign.Start)
.padding({top:4,bottom:4})
//时间
Text(this.transTime(item.sendTime))
.fontColor($r("app.color.text_second"))
.fontSize(14)
.width(60)
}
.padding({left:10,right:10})
.width('100%')
}
.onClick(()=>{
router.pushUrl({
url:'pages/ChatDetail/ChatDetail',
params:item.connectUser
})
})
.width('100%')
})
}
.divider({strokeWidth:1,color:$r("app.color.back_color")})
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
}
export default WeChat
在pages/Index.ets中显示WeChat
效果:
18. 处理首选项的长度限制问题
19. emitter线程内通信更新聊天记录
emitter可以支持不同线程内的数据通信
- 用法
- 通过on方法监听事件
- 通过emit触发事件
- 需要通过统一的eventId或者eventName来绑定来进行绑定
因为无论怎么发消息,都会讲过chat_store中的添加消息和删除消息,所以只需要在这里出发事件即可
eventHub 解决同一线程内通信。
emitter 解决同一线程 or 同一进程,不同线程内通信。
今天我们实现同一线程内通信。
- 在constants/index.ets中定义一个更新聊天记录事件名称
在添加或者删除消息的时候,触发该事件
在WeChat/Index.ets中监听
效果:
20. 点击主页的聊天进入聊天详情
当我们点击主页的聊天记录时,我们需要进入到详情中
- 和之前点击联系人进入聊天详情一致
- 注册ListItem的点击事件
ListItem()
...
.onClick(() => {
router.pushUrl({
url: 'pages/ChatDetail/ChatDetail',
params: item.connectUser
})
})
21. 聊天详情-长按显示浮层菜单
- 根据微信的交互,当我们长按一个信息的时候,应该会出现如图
- 所以我们需要根据手势事件 + 状态控制来显示这些
- 给/ChatDetail/Components/Message.ets定义一个显示Popup的状态
- 给每个文本消息绑定长按手势事件,控制是否展示浮层
在models/popup.ets中定义对应的类型
在Message中声明对应的数据
@State//弹层的数据:
popupList:PopupItem[]=[{
title: '听筒播放',
icon: $r("app.media.ic_public_ears")
},
{
title: '收藏',
icon: $r("app.media.ic_public_cube")
}, {
title: '转文字',
icon: $r("app.media.ic_public_trans_text")
}, {
title: '删除',
icon: $r("app.media.ic_public_cancel")
}, {
title: '多选',
icon: $r("app.media.ic_public_multi_select")
}, {
title: '引用',
icon: $r("app.media.ic_public_link")
}, {
title: '提醒',
icon: $r("app.media.ic_public_warin")
}]
封装getContent的builder方法
效果:
22. 删除某一条聊天记录
注册删除图标的事件
给元素注册点击事件
在子组件定义传入的一个方法
在父组件ChatDetail/ChatDetail.ets中传入delMessage方法
传入方法给子组件用
效果:
23. 实现删除整个的聊天记录
在苹果手机上,当我们在聊天记录中滑动某个人的聊天记录时,可以在右侧出现删除按钮,点击删除,我们就可以将整个的记录清除掉
在安卓手机上,长摁删除,和删除单个逻辑一样
这里我们讲解一下如何实现苹果手机上的删除
- List组件自带滑动菜单的功能能,只需要在ListItem尾部添加删除的元素即可,元素是自定义构建函数
实现getListEnd方法
效果:
24. 长按显示语音组件
现在我们基本上实现了文本消息的增删改查,接下来,我们要实现在长按“按住说话”时的语音输入组件
- 首先还是通过长按手势事件来控制
- 监听按住说话的长按手势事件
创建判断变量和给按钮绑定长按事件
使用bindContentConver模态控制
创建ChatDetail/Components/VoiceInput.ets组件,使用builder函数去调用
VoiceInput:
//语音输入组件
@Component
struct VoiceInput {
build() {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
// 显示关闭和文本
Row() {
Row() {
Image($r("app.media.ic_public_cancel"))
.width(30)
.height(30)
.fillColor($r("app.color.voice_round_font_color"))
}
.width(70)
.aspectRatio(1)
.borderRadius(35)
.justifyContent(FlexAlign.Center)
.backgroundColor($r("app.color.voice_round_color"))
.rotate({
angle: -10
})
Row() {
Text("文")
.fontSize(24)
.textAlign(TextAlign.Center)
.fontColor($r("app.color.voice_round_font_color"))
}
.width(70)
.aspectRatio(1)
.borderRadius(35)
.justifyContent(FlexAlign.Center)
.backgroundColor($r("app.color.voice_round_color"))
.rotate({
angle: 10
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({
left: 40,
right: 40
})
.margin({
bottom: 30
})
Stack() {
Image($r("app.media.ic_public_output"))
.width('100%')
.height(120)
.fillColor($r("app.color.bottom_color"))
.scale({
x: 1.2
})
Image($r("app.media.ic_public_recorder"))
.width(30)
.height(30)
.fillColor($r("app.color.bottom_voice_color"))
}
.width('100%')
}
}
.height('100%')
.backgroundColor($r("app.color.voice_back_color"))
}
}
export default VoiceInput
-2024/8/17
25. 组合手势移动设置不同状态
当我们长按之后,此时可以滑动左移,右移, 也可以保持留在原地,此时会有三种状态
- 左移取消
- 右移语音转文字
- 留在原地录制音频
- 在models新建voice.ets,导出一个枚举
在BottomInput中声明一个初始化状态
通过display获取屏幕实际宽度vp
使用组合手势处理 长按 + 移动的业务
在VoiceInput中根据状态控制显示内容
26. 根据不同状态显示不同内容
- 滑动到不同的状态时,切换显示内容的不同内容
- 在VoiceInput中封装一个builder用于条件渲染内容
封装一个builder
builder:
//用来展示不同区域内容
@Builder
getDisplayContent(){
if (this.voiceState === VoiceRecordEnum.Cancel){
Row(){
}
.width(100)
.height(80)
.borderRadius(20)
.backgroundColor($r("app.color.danger"))
.margin({left:30})
}else if (this.voiceState === VoiceRecordEnum.RecordIng){
Row(){
}
.width(180)
.height(80)
.borderRadius(20)
.backgroundColor($r("app.color.chat_primary"))
.margin({left:30})
}else if (this.voiceState === VoiceRecordEnum.Transfer){
Row(){
}
.width(280)
.height(120)
.borderRadius(20)
.backgroundColor($r("app.color.chat_primary"))
.margin({left:30})
}
}
27. 申请/检测录音权限
录音实现过程:
发送语音
和发送消息是一样的,只不过消息的本质上是音频文件,mp3, wav, m4a,
微信的发送语音原理- 在本地通过手机侧录制一段音频,形成 文件(写入磁盘)or buffer(内存形式)or 流
聊天记录只要存在
微信聊天记录 存于首选项里面- 沙箱文件路径-指定的就是 音频 视频 照片
使用AudioCapturer录制音频涉及到AudioCapturer实例的创建、音频采集参数的配置、采集的开始与停止、资源的释放等,目前只支持真机测试
使用Audio
权限 麦克风 照相机 这些权限只会申请一次 就记住啦
- 首先在module.json5中配置权限申请麦克风权限
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:MICROPHONE_REASON",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "always"
}
}
- 在media/string.json中配置对应的reason属性
{
"name": "MICROPHONE_REASON",
"value": "用于发送语音"
}
权限分两个
系统权限 . system_agent . 比如网络权限-不需要手动申请,不需要用户同意
需要用户授权的权限 user_agent 。麦克风-读写通讯录 必须得用户同意
- 在长按的逻辑中判断用户是否已经申请了麦克风权限,如果没有则申请
项目一启动就申请一下 麦克风
ability的onCreate中就可以实现
- 第一道防线:在ability中申请麦克风权限
async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
const manager = abilityAccessCtrl.createAtManager() // 创建程序控制管理器
await manager.requestPermissionsFromUser(this.context,
[
"ohos.permission.MICROPHONE"
])
}
效果:只会申请一次
如果已经申请过权限,再次去请求权限弹窗已经不出来啦!!!!
当我们按住说话时,都得去检查一下
第二道防线:
需要CheckAceessTokenSync方法来获取是否拥有了权限
但是CheckAceessTokenSync 需要tokenId, tokenId又需要 一个方法来获取
28. AudioCapturer-实现录音功能
视频:第22集
因为聊天记录要存于文件,所以说我们要创建文件。
我们此时此刻要封装一个公共的操作类。
- 新建utils/file_operate.ets(文件操作)
因为后续视频和音频照片会频繁创建文件,所以封装一个公共的操作类
新建utils/audio_recorder.ets文件
//录音的单例类,AudioCapturer:音频采集器
import {audio} from '@kit.AudioKit'
import { fileIo } from '@kit.CoreFileKit';
import fileIO from '@ohos.fileio';
export class AudioCapturer{
//声明一个采音对象的单例对象
static audioCapturer:audio.AudioCapturer
//采样配置
static audioStreamInfo: audio.AudioStreamInfo = {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100,
channels: audio.AudioChannel.CHANNEL_2,//channel双声道
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
};
//录音配置
static audioCapturerInfo: audio.AudioCapturerInfo = {
source: audio.SourceType.SOURCE_TYPE_MIC, // 音源类型
capturerFlags: 0 // 音频采集器标志
};
//总体配置
static audioCapturerOptions:audio.AudioCapturerOptions={
streamInfo:AudioCapturer.audioStreamInfo,
capturerInfo:AudioCapturer.audioCapturerInfo
};
//是否正在录制:
static recordIng: boolean = false
//初始化音频采集器的方法:
static async init(){
if (!AudioCapturer.audioCapturer) {
AudioCapturer.audioCapturer = await audio.createAudioCapturer(AudioCapturer.audioCapturerOptions)
}
}
//开始录音
static async start(path:string){
if (!AudioCapturer.audioCapturer) return
try{
//只有存在采集器的情况下,我们就开始收集录音
await AudioCapturer.audioCapturer.start()//开始录音
AudioCapturer.recordIng = true//开始录音的标志
//此时可以收集声音了,但是你必须得把声音 输出的一段buffer写入到一个固定文件中或者直接播出这个buffer
//拿到文件的具体的内容
const file = fileIo.openSync( path, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE )//如果不存在就创建文件
//录音要一段段写,每次产生一段buffer,我们要把buffer写入到file中区,最终完成文件的录制
//拿原有的file的buffer的长度
let fd = file.fd//文件标识
const statFile = fileIo.statSync(file.fd)
let bufferSize = statFile.size//原有文件的buffer长度,是一个写入文件的基准点
//while循环,可以使用async,await可以等待
while (AudioCapturer.recordIng) {
//只要这个标记为开始,那么我就一直采集一直写入文件
let size = await AudioCapturer.audioCapturer.getBufferSize()//获取缓冲区的长度
let buffer = await AudioCapturer.audioCapturer.read(size,true)
//buffer是当前这一段的录音内容
fileIO.writeSync(fd,buffer,{
offset:bufferSize,
length:buffer.byteLength
})
bufferSize += buffer.byteLength//追加长度
}
}
catch (error){
AlertDialog.show( { message:error.message } )
}
}
//结束录音
static async stop(){
AudioCapturer.recordIng = false
//什么情况下可以停止收音
if (AudioCapturer.audioCapturer) {
if (AudioCapturer.audioCapturer.state === audio.AudioState.STATE_RUNNING || audio.AudioState.STATE_PAUSED) {
await AudioCapturer.audioCapturer.stop()//停止录音
}
}
}
//释放资源
static async release(){
if (AudioCapturer.audioCapturer) {
AudioCapturer.recordIng = false
AudioCapturer.audioCapturer.release()
}
}
}
- 在BottomInput中使用
- 初始化时进行init,卸载组件时进行release
没有麦克风,无法调试555
29. set Interval和clear Interval计算音频时长
- 只要开始录音-就计时,暂停,就不再计时,结束停止计时
- 定义时间变量
定义开始和结束两个方法
开始计时和结束计时
效果:
30. 创建语音消息发送
当松手时,状态为录制发送时,将语音消息发送一条信息
- 在BottomInput中导入当前用户和对话用户
在BottomInput中声明一个方法,用来调用父组件方法的
新建一个发送语音信息的方法
松手时,发送语音信息
在父组件传入发送语音的方法
传递方法
效果:
- 2024/8/18
31. 渲染语音消息组件
加上一个判断,当录制时长小于1秒时,处理语音销毁
当发送的消息为语音时,渲染语音条
- 实现多个方法
//实现多个builder
@Builder
getTextContent(){
//文本消息
Text(this.currentMessage.messageContent)
.backgroundColor(this.isOwnMessage?$r("app.color.second_primary"):$r("app.color.white"))
.fontColor($r("app.color.text_primary"))
.padding(10)
.margin({left:10,right:10})
.borderRadius(4)
}
//获取语音消息的宽度width
getAudioWidth(){
//最短
//最长
let minWidth:number = 20//百分比
let maxWidth:number = 90
let calcWith = minWidth + 100 * this.currentMessage.sourceDuration / 60
if (calcWith>maxWidth) return maxWidth+"%"
return calcWith+"%"
}
//实现多个builder
@Builder
getAudioContent(){
//语音消息
Row({space:5}){
Text(`${this.currentMessage.sourceDuration}'`)
Image($r("app.media.ic_public_recorder"))
.width(20)
.height(20)
.rotate({
angle:this.isOwnMessage ? 180 : 0
})
}
.justifyContent(this.isOwnMessage?FlexAlign.End:FlexAlign.Start)
.width(this.getAudioWidth())//长度
.backgroundColor(this.isOwnMessage?$r("app.color.chat_primary"):$r("app.color.white"))
.padding(10)
.margin({left:10,right:10})
.borderRadius(4)
.direction(this.isOwnMessage?Direction.Ltr:Direction.Rtl)
}
绑定给消息row
效果:
32. AudioRenderer-播放语音
utils下创建AudioRender静态类
//工具的单例类:实现AudioRenderer语音播放PCM
import { audio } from '@kit.AudioKit'
import { fileIo } from '@kit.CoreFileKit';
export class AudioRenderer{
//实例
static renderModel:audio.AudioRenderer
//采样配置
static audioStreamInfo: audio.AudioStreamInfo = {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100,
channels: audio.AudioChannel.CHANNEL_2,//channel双声道
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
};
static audioRendererInfo:audio.AudioRendererInfo = {
rendererFlags:0,//普通音频渲染器,1是低时延
usage:audio.StreamUsage.STREAM_USAGE_MOVIE//场景
}
//创建播放器的参数
static audioRendererOptions:audio.AudioRendererOptions = {
streamInfo:AudioRenderer.audioStreamInfo,
rendererInfo:AudioRenderer.audioRendererInfo
}
//初始化方法
static async init(){
if (!AudioRenderer.renderModel) {
//创建实例
AudioRenderer.renderModel = await audio.createAudioRenderer(AudioRenderer.audioRendererOptions)
}
}
//音频的释放和暂停
static async stop(){
if (AudioRenderer.renderModel) {
await AudioRenderer.renderModel.stop()//stop
}
}
//播放,filePath指的是沙箱路径
static async start(filePath:string,callback?:()=>void){
if (AudioRenderer.renderModel && filePath) {
//先管一下状态
//如果你在播的情况下,还能播吗?
let statList:audio.AudioState[]=[
audio.AudioState.STATE_PREPARED,
audio.AudioState.STATE_PAUSED,
audio.AudioState.STATE_STOPPED]
if (!statList.includes(AudioRenderer.renderModel.state)) {
//不包含这三种状态的情况下,此时就不能播放了
return
}
//此时可以播放
await AudioRenderer.renderModel.start()
//读写filePath文件,一段段的读取,读完关闭文件
const file = fileIo.openSync(filePath,fileIo.OpenMode.READ_ONLY) //读这个文件的buffer
//AudioRenderer.renderModel.write()
const stat =fileIo.statSync(file.fd) //取文件的详细信息
const bufferSize = await AudioRenderer.renderModel.getBufferSize() //获取缓冲区的长度
let buf = new ArrayBuffer(bufferSize) //创建一个缓冲区对象,长度是音频采集器长度的长度
let totalSize = 0//总量的值
while (totalSize<stat.size){
fileIo.readSync(file.fd,buf,{
offset:totalSize,
length:bufferSize
})
// 坑点: write是个异步方法 , 需要一段段的去播放 需要写入完这一段以后 再去写入下一段
await AudioRenderer.renderModel.write(buf) // 往音频采集器写入缓冲区内容,播完再下一段
if (AudioRenderer.renderModel!.state.valueOf() === audio.AudioState.STATE_RELEASED) { // 如果渲染器状态为released,关闭资源
fileIo.close(file);
}
// 此时要继续
totalSize += bufferSize
}
// 关闭文件
fileIo.closeSync(file.fd)
AudioRenderer.stop() // 关闭
callback && callback()//执行回调函数,控制小喇叭动画关闭的
}
}
//释放
static async release(){
if (AudioRenderer.renderModel) {
await AudioRenderer.renderModel.release()//释放
}
}
}
在详情中初始化(ChatDetail)
点击声音播放
播放声音图片帧
- 放入到资源目录下
声明状态audioState
替换声音消息的位置,使用图片帧组件
播放时设置状态
在AudioRenderer中实现传入一个回调函数,播放结束时,执行
效果:
33. 删除消息时,删除临时语音文件
在删除单条消息时,如果存在临时引用路径则直接删除
删除整个聊天记录时,得遍历查找需要在首选项中的删除语音的方法实现
34. AVplayer增加提示音
给rawfile目录下放置以下声音的素材
播放音频通过AVPlayer进行播放,封装AvPlayer播放类
- 和播放语音一样,当存在多个音效需要播放时,也是新的音效会替换旧的音效,所以我们这里仍然以单例模式为主,当然AVPlayer也可以实现多实例播放,创建多个实例即可
- 把音频文件放入rawfile
新建utils/av_player.ets,多实例播放
//提示音,面向对象
import { media } from '@kit.MediaKit'
export class AvPlayer{
avplayer:media.AVPlayer | null =null //初始属性
async init(){
this.avplayer = await media.createAVPlayer()//创建AVplayer
this.watchCallback()
}
//监听状态的变化
watchCallback(){
this.avplayer?.on("stateChange",(state:string)=>{
switch (state) {
//只要给AVplayer的url或者fdsrc赋完值就可以触发初始化
case 'initialized': // avplayer 设置播放源后触发该状态上报
console.info('AVPlayer state initialized called.');
this.avplayer?.prepare()//准备播放
break;
case 'prepared': // prepare调用成功后上报该状态机cesi
console.info('AVPlayer state prepared called.');
this.avplayer?.play()//播放
break;
case 'playing': // play成功调用后触发该状态机上报
console.info('AVPlayer state playing called.');
break;
case 'paused': // pause成功调用后触发该状态机上报
console.info('AVPlayer state paused called.');
break;
case 'completed': // 播放结束后触发该状态机上报
console.info('AVPlayer state completed called.');
this.avplayer?.reset(); //调用播放结束接口,重置
break;
case 'stopped': // stop接口成功调用后触发该状态机上报
console.info('AVPlayer state stopped called.');
this.avplayer?.reset(); //调用播放结束接口
break;
case 'released':
console.info('AVPlayer state released called.');
this.avplayer?.reset(); //调用播放结束接口
break;
case 'error':
console.error('AVPlayer state error called.')
this.avplayer?.reset(); //调用播放结束接口
break;
default:
console.info('AVPlayer state unknown called.');
break;
}})
}
//播放的方法:
async play(fileName:string){
//资源管理器获取文件的方法:
const fillDes = await getContext().resourceManager.getRawFd(fileName)
this.avplayer!.fdSrc = fillDes//赋值url或者fdSRC会造成初始化
}
}
在ChatDetail中初始化时进行初始化
发送消息播放提示音:
35. 渲染底部加号菜单
继续使用之前定义的models/popup.ets中的PopupItem类型
在BottomInput中声明底部的八个数据
//底部加号弹窗标识数据:
@State
bottomList: PopupItem[] = [{
icon: $r('app.media.ic_public_photo'),
title: '照片',
}, {
icon: $r('app.media.ic_public_carema'),
title: '拍摄',
}, {
icon: $r('app.media.ic_statusbar_gps'),
title: '位置',
}, {
icon: $r('app.media.ic_public_voice'),
title: '语音输入',
}, {
icon: $r("app.media.ic_public_collect"),
title: '收藏',
}, {
icon: $r("app.media.ic_public_contacts_filled"),
title: '个人名片',
}, {
icon: $r("app.media.ic_public_folder_filled"),
title: '文件',
}, {
icon: $r("app.media.ic_public_music_filled"),
title: '音乐',
}]
@State// 是否显示底部
showBottomCard: boolean = false
在底部渲染
声明一个自定义渲染builder
点击右侧加号进行折叠展开
当点击录音按键,关闭弹窗
点击空屏幕也要关闭:因为在不同的组件,不在buttom了
//eventhub emitter 上下文传递
还要控制键盘的避让,声明一个变量
绑定给textInput
打开弹窗关闭键盘
点击打字时,关闭弹窗
效果:
- 推送 PushKit . - 不需要鸿蒙端写代码
需要服务端支持
讲思路帮大家去面试
Kit能力太多了 - 50000个API
- 2024/8/18
36. picker发送照片消息
打开相册- 不需要权限-获取照片或者视频
等同于前端的 input type='file'
致命问题: 目前模拟器不允许拖照片进去
怎么办?
copy_image: 用来解决鸿蒙Next版本的模拟器中无法拖入图片到相册的一个解决方案
相册不允许随意的写入
- 安全组件 SaveButton 组件 给5秒钟的时间获取写入权限 5秒收回
- 给华为发邮件 将自己的包名和申请相册理由写入
点击图片时,选择多张图片,进行发送
先给图片两个字绑定点击事件:点击调用sendPhoto方法
在BottomInput中定义sendPhoto方法,来取相册照片然后存入沙箱,生成一个列表list,然后调用子组件的发消息方法sendImageMessage,把list给这个方法
定义:子组件接入一个方法sendImageMessage,然后把list给父组件
父组件里调用sendImageMessage,接收到子组件传过来的list,然后调用自己的方法
父组件定义方法,把list给这个父组件自己的方法sendImgMessage,把list加入到messlist,存入首选项
然后父组件的这个方法再把list给message组件进行渲染:显示出图片
效果:
37. CustomDialogController图片预览
图片需要长按删除
当点击图片时,弹出层进行渲染
新建一个ChatDetail/Components/PreviewDialog.ets文件
在message点击图片调用
父组件传入方法ChatDetail,从子组件message提醒父组件chatdetial中的函数去执行
父组件调用自身函数给信息给预览组件PreviewDialog去渲染
38. cameraPicker唤起相机拍照发送照片
两种方式
- 极其复杂的需要各种各样的对象
- 最简单的方式 。picker
BottomInput.ets 注册唤起相机
实现打开相机方法
39. cameraPicker发送视频
视频:第24集
BottomInput需要根据类型不同处理不同的图片和视频,对之前代码修改
在Message.ets中添加视频消息判断
渲染一个视频模式下的builder
定义VideoController
修改预览图片组件:
在ChatDetail中传入对应的参数
判断类型
- 2024/8/19
40. MapCompoment 和 MapCompomentController实现发送地图
华为地图必须调试证书才能显示
参考链接:官方文档
注意:目前只能采用调试证书查看地图,发布证书目前模拟器无法渲染地图
生成签名证书指纹
需要进行一系列的配置操作
- p12文件
我的密码:xz。。。。。。
- 密码长度最少8位
- csr文件
- cer文件-调试证书,新版变了2024.8.20
打开AGC平台
点击申请调试证书
新增证书
申请调试证书
下载证书
- p7b文件-调试证书
新建一个项目
AGC开启地图服务
配置证书
配置应用包和证书
配置项目的module.json5中的应用指纹
- 地图展示(Windows模拟器无法展示)发开步骤
1.导入Map Kit相关模块
2.新建地图组件-ChatDetail/Components/Locaiton.ets
//定位组件
import { MapComponent, mapCommon, map } from '@kit.MapKit';
import { AsyncCallback } from '@kit.BasicServicesKit';
@Component
struct Location {
build() {
Row() {
MapComponent({
mapOptions: {
position: {
target: {
latitude: 39.9,//经纬度
longitude: 116.4
},
zoom: 10
}
},
mapCallback: () => {}
}).width('100%').height('100%')
}
.height('100%')
}
}
export default Location
定义变量
绑定弹层
定义位置的builder
效果:
41. geoLocationManager地图的定位
- 1.申请读取位置权限
- 2.获取经纬度
- 3.设置经纬度
设置经纬度位置权限module.json5
在ability中申请定位
效果:
修改location组件为:
//定位组件
import { MapComponent, mapCommon, map } from '@kit.MapKit';
import { AsyncCallback } from '@kit.BasicServicesKit';
import { geoLocationManager } from '@kit.LocationKit';
@Component
struct Location {
//初始化地图初始属性
private mapOption: mapCommon.MapOptions = {
position: {
target: {
latitude: 39.9,
longitude: 116.4
},
zoom: 12
}
};
private callback?: AsyncCallback<map.MapComponentController>;
private mapController?: map.MapComponentController;
@Link
currentAddress:string//把街道名称传出去
aboutToAppear(): void {
// 地图初始化的回调,地图渲染完成后执行这个回调
this.callback = async (err, mapController) => {
if (!err) {
// 获取地图的控制器类,用来操作地图
this.mapController = mapController;
this.mapController.setMyLocationEnabled(true) // 允许我的定位
this.mapController.setMyLocationControlsEnabled(true) // 允许我的定位组件显示
// 此时此刻 可以拿定位了
this.getLocation()
}
};
}
//获取物理位置
async getLocation() {
try {
const result = await geoLocationManager.getCurrentLocation()
// result中有经纬度
this.mapController?.setMyLocation({
latitude: result.latitude,//获取经度
longitude: result.longitude,
altitude: 0,
accuracy: 0,
speed: 0,
timeStamp: 0,
direction: 0,
timeSinceBoot: 0,
altitudeAccuracy: 0,
speedAccuracy: 0,
directionAccuracy: 0,
uncertaintyOfTimeSinceBoot: 0,
sourceType: 1
})
//地图上你所看到的东西实际上是照相机对象的镜头位置
let cameraUpdate:map.CameraUpdate=map.newCameraPosition({
target:{
longitude:result.longitude,
latitude:result.latitude
},zoom:16//缩放级别
})//新照相机的位置
this.mapController?.moveCamera(cameraUpdate)
//看到具体位置
//利用花瓣地图的浮层
this.mapController?.addMarker({
position:{
longitude:result.longitude,
latitude:result.latitude
},
title:'你他妈的当前位置',
clickable:true
})
//在已定位的的位置画个圈
this.mapController?.addCircle({
radius:500,//半径五百米
center:{
longitude:result.longitude,
latitude:result.latitude
},
fillColor:0XFF00FFFF
})
//要拿经纬度点的街道名称
const res = await geoLocationManager.getAddressesFromLocation({
longitude:result.longitude,
latitude:result.latitude
})
if (res.length) {
this.currentAddress = res[0].placeName as string
}
} catch (error) {
AlertDialog.show({ message: error.message })
}
}
build() {
Row() {
MapComponent({
mapOptions: this.mapOption,
mapCallback:this.callback//地图初始化完成后会调用我们的回调函数,回调函数会给你传过来一个mapController
})
.width('100%').height('100%')
}
.height('100%')
}
}
export default Location
声明一个变量给父组件赋值来发送街道名称的消息,通过link修饰符来实现子传父,也可以用provide 和consume
子组件通过逆地理编码获取街道名称给currentAddress
父组件定义一个接受变量
父子组件绑定
,
点击确定,拿到了子组件给的currentAddress,之后发送文本消息出去,清空currentAdd,关闭弹窗
42. Core Speech语音文字互转
需要注意: 只能支持真机,只能支持采样率为16000赫兹的pcm音频识别
- AudioCaputur/AudioRenderer 都要换成16000赫兹
当语音划到右侧时,进行语音转化,新建utils/VoiceTransfer.ets文字语音转化单例:
import { BusinessError } from '@ohos.base';
import { speechRecognizer, textToSpeech } from '@kit.CoreSpeechKit';
import { util } from '@kit.ArkTS';
import fileIo from '@ohos.file.fs';
import { promptAction } from '@kit.ArkUI';
export class VoiceTransfer {
// 语音识别成文字
private static asrEngine: speechRecognizer.SpeechRecognitionEngine;
// 文字转语音
private static ttsEngine: textToSpeech.TextToSpeechEngine
private static voiceExtraParam: Record<string, Object> = {
"style": 'interaction-broadcast',
"locate": 'CN',
"name": 'EngineName'
};
private static voiceInitParamsInfo: textToSpeech.CreateEngineParams = {
language: 'zh-CN',
person: 0,
online: 1,
extraParams: VoiceTransfer.voiceExtraParam
};
// 文本转语音
static async textToVoice(speakText: string) {
if (!VoiceTransfer.ttsEngine) {
// 文本转语音引擎创建
VoiceTransfer.ttsEngine = await textToSpeech.createEngine(VoiceTransfer.voiceInitParamsInfo)
}
let extraParam: Record<string, Object> = {
"speed": 1,
"volume": 1,
"pitch": 1,
"languageContext": 'zh-CN',
"audioType": "pcm"
}
let speakParams: textToSpeech.SpeakParams = {
requestId: util.generateRandomUUID(),
extraParams: extraParam
}
VoiceTransfer.ttsEngine.speak(speakText, speakParams)
}
// 设置创建引擎参数
private static textExtraParams: Record<string, Object> = { "locate": "CN", "recognizerMode": "short" }
private static textInitParamsInfo: speechRecognizer.CreateEngineParams = {
language: 'zh-CN',
online: 1,
extraParams: VoiceTransfer.textExtraParams
};
private static sessionId: string = "" // 会话id
static async VoiceToText(path: string, call: (result: speechRecognizer.SpeechRecognitionResult) => void) {
try {
if (!VoiceTransfer.asrEngine) {
VoiceTransfer.asrEngine = await speechRecognizer.createEngine(VoiceTransfer.textInitParamsInfo)
}
if (VoiceTransfer.asrEngine.isBusy()) return // 说明正在识别不用处理a
// 创建回调对象
let setListener: speechRecognizer.RecognitionListener = {
// 开始识别成功回调
onStart(sessionId: string, eventMessage: string) {
console.info("onStart sessionId: " + sessionId + "eventMessage: " + eventMessage);
},
// 事件回调
onEvent(sessionId: string, eventCode: number, eventMessage: string) {
console.info("onEvent sessionId: " + sessionId + "eventCode: " + eventCode + "eventMessage: " + eventMessage);
},
// 识别结果回调,包括中间结果和最终结果
onResult(sessionId: string, result: speechRecognizer.SpeechRecognitionResult) {
call(result) // 返回语音识别内容
if(result.isLast) {
VoiceTransfer.asrEngine.finish(sessionId) // 结束
}
},
// 识别完成回调
onComplete(sessionId: string, eventMessage: string) {
// VoiceTransfer.asrEngine.finish(sessionId) // 结束
console.info("onComplete sessionId: " + sessionId + "eventMessage: " + eventMessage);
},
// 错误回调,错误码通过本方法返回
// 返回错误码1002200002,开始识别失败,重复启动startListening方法时触发
// 更多错误码请参考错误码参考
onError(sessionId: string, errorCode: number, errorMessage: string) {
console.error("onError sessionId: " + sessionId + "errorCode: " + errorCode + "errorMessage: " + errorMessage);
},
}
// 设置回调
VoiceTransfer.asrEngine.setListener(setListener);
let audioParam: speechRecognizer.AudioInfo = {
audioType: 'pcm',
sampleRate: 16000,
soundChannel: 1,
sampleBit: 16
};
let extraParam: Record<string, Object> = { "vadBegin": 2000, "vadEnd": 3000, "maxAudioDuration": 60000 };
let recognizerParams: speechRecognizer.StartParams = {
sessionId: util.generateRandomUUID(),
audioInfo: audioParam,
extraParams: extraParam
};
VoiceTransfer.sessionId = recognizerParams.sessionId
// 调用开始识别方法
VoiceTransfer.asrEngine.startListening(recognizerParams);
VoiceTransfer.writeAudio(path)
} catch (error) {
// AlertDialog.show({
// message: JSON.stringify(error)
// })
}
}
// 写音频流
static async writeAudio(path: string) {
let file = fileIo.openSync(path, fileIo.OpenMode.READ_WRITE); // 读文件
// let stat = fileIo.statSync(file.fd)
try {
let buf: ArrayBuffer = new ArrayBuffer(1280);
let totalSize: number = 0
while (totalSize < fileIo.statSync(file.fd).size) {
fileIo.readSync(file.fd, buf, {
offset: totalSize,
length: 1280
})
let unit8Array: Uint8Array = new Uint8Array(buf);
VoiceTransfer.asrEngine.writeAudio(VoiceTransfer.sessionId, unit8Array);
// 这里必须加延迟才可以
await new Promise<boolean>((resolve) => {
setTimeout(() => resolve(true), 40)
})
// 延迟时间
totalSize = totalSize + 1280;
}
} catch (e) {
console.error("read file error " + e);
} finally {
fileIo.closeSync(file)
VoiceTransfer.stopTransfer()
}
}
// 停止转换文字
static async stopTransfer() {
if (VoiceTransfer.asrEngine && VoiceTransfer.asrEngine.isBusy() && VoiceTransfer.sessionId) {
VoiceTransfer.asrEngine.finish(VoiceTransfer.sessionId)
}
}
}
当拖入到右侧转化时处理,绑定给手势,识别后把结果给子组件voiceInput
在VoiceInput中显示
松手发送;
实现点击转文字
创建点击事件,和transferResult接收
在消息下面显示转的文字内容
43. textToSpeech实现AI小艺播报
在原有语音转文本的基础上,添加文本转语音的方法和属性
方法上一集已经写好了
得到回复消息播报:
失败了 fuck 这个要看新的开发笔记。
44. 利用buffer值来计算语音波峰
当长按语音时,输出波峰动画
拿到一个buffer,平均分成20份,20份的平均值除以32767得到比例,然后乘以高度算出波峰。
- 使用线程通信(录音时输出波段buffer)
voiceInput中用emitter.on监听buffer,创建一个数组来放峰值
显示在中间动画:
- 2024/8/20黑神话悟空发售
45. 主页加号下拉菜单
在顶部增加一个搜索和下拉菜单
定义下拉的数据选项
定义控制弹层出现的变量
绑定给加号
定义builder函数来渲染弹层样式
46. scanBarcode扫码功能
注册点击事件,实现扫码方法
47. 收付款页面样式
点击收付款生成一个二维码
- 注册收付款点击事件-跳转到收付款页面
创建一个收付款页面 pages/PayQrCode/PayQrCode.ets
//支付页面
import { router } from '@kit.ArkUI'
@Entry
@Component
struct PayQrCode {
build() {
Column() {
Row() {
Stack({ alignContent: Alignment.Start }) {
Image($r('app.media.ic_public_arrow_left'))
.width(30)
.height(30)
.fillColor($r("app.color.white"))
.onClick(() => {
router.back()
})
.zIndex(2)
Text("收付款")
.width('100%')
.textAlign(TextAlign.Center)
.fontSize(16)
.fontColor($r('app.color.white'))
}
.height(50)
}
.padding({
left: 10,
right: 10
})
Row() {
Column() {
Row({ space: 10 }) {
Image($r("app.media.ic_public_scan"))
.width(20)
.height(20)
.fillColor($r("app.color.pay_back"))
Text("付款码")
.fontColor($r("app.color.pay_back"))
}
.width('100%')
.justifyContent(FlexAlign.Start)
.height(60)
.border({
width: {
bottom: 0.5
},
color: $r("app.color.border_color")
})
// 显示码
Column({ space: 20 }) {
Text("优先使用xx银行储蓄卡付款")
.fontColor($r("app.color.text_second"))
.fontSize(12)
}
.margin({
top: 10,
bottom: 10
})
}
.padding(10)
.borderRadius(4)
.width("100%")
.backgroundColor($r("app.color.white"))
}
.padding({
left: 10,
right: 20
})
}
.width('100%')
.height('100%')
.backgroundColor($r("app.color.pay_back"))
}
}
效果:
48. QRCode二维码 & generateBarcode条形码
- 随机生成一个基于当前用户的随机数
- 使用二维码组件显示二维码
- 使用生成条形码工具生成条形码
- 30秒一刷新对应的码
- 定义一个二维码的随机数,以及十秒钟更新一次
显示在页面上
效果:
- 使用二维码生成工具生成条形码
模拟器不可用-待解决
- 定义一个状态接收PixelMap,定义生成条形码方法
显示在页面:
效果:
49. 我的页面结构
在pages下新建My.ets组件
准备我的页面,这里没有什么功能,只有基本的UI,复制粘贴即可
@Component
struct My {
@Builder
getRenderItem (left: string, rightClick?: () => void) {
Row() {
Text(left)
Image($r("app.media.ic_public_right"))
.width(16)
.height(16)
}
.width('100%')
.padding({
left: 20,
right: 20
})
.backgroundColor($r("app.color.white"))
.height(60)
.justifyContent(FlexAlign.SpaceBetween)
.border({
color: $r("app.color.border_color"),
width: {
bottom: 0.5
}
})
}
build() {
Column() {
// 顶部
Row () {
Row({ space: 10 }) {
Image("https://img0.baidu.com/it/u=3645023358,1091235964&fm=253&fmt=auto&app=120&f=JPEG?w=547&h=300")
.width(60)
.height(60)
.borderRadius(6)
Column({ space: 10 }) {
Text("wakeupwsy").fontSize(18).fontColor($r('app.color.text_primary'))
Text("微信号:991129").fontColor($r('app.color.text_second')).fontSize(14)
}.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
Image($r("app.media.ic_public_right"))
.width(16)
.height(16)
.fillColor($r('app.color.text_second'))
}
.justifyContent(FlexAlign.SpaceBetween)
.padding(30)
.width('100%')
.backgroundColor($r("app.color.white"))
Row().height(10)
this.getRenderItem("服务")
Row().height(10)
this.getRenderItem("收藏")
this.getRenderItem("朋友圈")
this.getRenderItem("卡包")
this.getRenderItem("表情")
Row().height(10)
this.getRenderItem("设置")
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.back_color'))
}
}
export default My
绑定在index
效果:
将我的页面的数据变成当前的用户信息,拿全局变量
绑定为我的信息
效果:
50. 发现页面结构链接
新建pages/Find/Find.ets
@Preview
@Component
struct Find {
@Builder
getRenderItem(left: string, rightClick?: () => void) {
Row() {
Text(left)
Image($r("app.media.ic_public_right"))
.width(16)
.height(16)
}
.width('100%')
.padding({
left: 20,
right: 20
})
.backgroundColor($r("app.color.white"))
.height(60)
.justifyContent(FlexAlign.SpaceBetween)
.border({
color: $r("app.color.border_color"),
width: {
bottom: 0.5
}
})
}
build() {
Column() {
Row() {
Text("发现")
}
.justifyContent(FlexAlign.Center)
.height(40)
.width('100%')
Scroll() {
Column() {
// 顶部
this.getRenderItem("朋友圈")
Row().height(10)
this.getRenderItem("视频号")
this.getRenderItem("直播")
Row().height(10)
this.getRenderItem("扫一扫")
this.getRenderItem("摇一摇")
Row().height(10)
this.getRenderItem("看一看")
this.getRenderItem("搜一搜")
Row().height(10)
this.getRenderItem("附近")
Row().height(10)
this.getRenderItem("购物")
this.getRenderItem("游戏")
Row().height(10)
this.getRenderItem("小程序")
Row().height(10)
}
}
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.back_color'))
}
}
export default Find
在index中绑定
效果:
51. 完结
更多推荐
所有评论(0)