基于HarmonyOS_NEXT的仿微信聊天原生应用开发笔记

                                                                                                                 

涉及调用大模型接口实现机器人自动回复、音视频处理、发语音发相册图片、语音文字互转、相机拍照录像、发送位置、扫描二维码、生成二维码等一些能力,开发笔记,大佬勿喷。

1. 创建一个WeTalk项目

新建项目:

将讲义中的图片添加到resources/base/media目录下

📎图片.zip

  • 拷贝所有图片

  • 导入所有基本色值到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可以支持不同线程内的数据通信

  • 用法
  1. 通过on方法监听事件
  2. 通过emit触发事件
  3. 需要通过统一的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. 组合手势移动设置不同状态

当我们长按之后,此时可以滑动左移,右移, 也可以保持留在原地,此时会有三种状态

  1. 左移取消
  2. 右移语音转文字
  3. 留在原地录制音频
  • 在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)

点击声音播放

播放声音图片帧

声音帧动画.zip

  • 放入到资源目录下

声明状态audioState

替换声音消息的位置,使用图片帧组件

播放时设置状态

在AudioRenderer中实现传入一个回调函数,播放结束时,执行

效果:

33. 删除消息时,删除临时语音文件

在删除单条消息时,如果存在临时引用路径则直接删除

删除整个聊天记录时,得遍历查找需要在首选项中的删除语音的方法实现

34. AVplayer增加提示音

给rawfile目录下放置以下声音的素材

📎声音.zip

播放音频通过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版本的模拟器中无法拖入图片到相册的一个解决方案

相册不允许随意的写入

  1. 安全组件 SaveButton 组件 给5秒钟的时间获取写入权限 5秒收回
  2. 给华为发邮件 将自己的包名和申请相册理由写入

点击图片时,选择多张图片,进行发送

先给图片两个字绑定点击事件:点击调用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. 完结

Java可以使用HttpURLConnection或者HttpClient来发送get和post请求到微信接口。以下是示例代码: 使用HttpURLConnection发送get请求: ```java URL url = new URL("https://api.weixin.qq.com/cgi-bin/user/get?access_token=ACCESS_TOKEN&next_openid=NEXT_OPENID"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("GET"); connection.connect(); int responseCode = connection.getResponseCode(); if (responseCode == HttpURLConnection.HTTP_OK) { BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream())); String inputLine; StringBuffer response = new StringBuffer(); while ((inputLine = in.readLine()) != null) { response.append(inputLine); } in.close(); System.out.println(response.toString()); } else { System.out.println("GET request failed, response code: " + responseCode); } ``` 使用HttpURLConnection发送post请求: ```java String url = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN"; URL obj = new URL(url); HttpURLConnection con = (HttpURLConnection) obj.openConnection(); con.setRequestMethod("POST"); // 设置post请求参数 String urlParameters = "json data..."; con.setDoOutput(true); DataOutputStream wr = new DataOutputStream(con.getOutputStream()); wr.writeBytes(urlParameters); wr.flush(); wr.close(); int responseCode = con.getResponseCode(); if (responseCode == HttpURLConnection.HTTP_OK) { BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream())); String inputLine; StringBuffer response = new StringBuffer(); while ((inputLine = in.readLine()) != null) { response.append(inputLine); } in.close(); System.out.println(response.toString()); } else { System.out.println("POST request failed, response code: " + responseCode); } ``` 使用HttpClient发送get请求: ```java CloseableHttpClient httpClient = HttpClients.createDefault(); HttpGet httpGet = new HttpGet("https://api.weixin.qq.com/cgi-bin/user/get?access_token=ACCESS_TOKEN&next_openid=NEXT_OPENID"); CloseableHttpResponse response = httpClient.execute(httpGet); try { HttpEntity entity = response.getEntity(); String responseBody = EntityUtils.toString(entity, "UTF-8"); EntityUtils.consume(entity); System.out.println(responseBody); } finally { response.close(); } ``` 使用HttpClient发送post请求: ```java CloseableHttpClient httpClient = HttpClients.createDefault(); HttpPost httpPost = new HttpPost("https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN"); // 设置post请求参数 StringEntity entity = new StringEntity("json data...", ContentType.APPLICATION_JSON); httpPost.setEntity(entity); CloseableHttpResponse response = httpClient.execute(httpPost); try { HttpEntity responseEntity = response.getEntity(); String responseBody = EntityUtils.toString(responseEntity, "UTF-8"); EntityUtils.consume(responseEntity); System.out.println(responseBody); } finally { response.close(); } ```
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值