目录
前言:
有好多todo没实现,这里总结一下这两天遇到的坑及简单的聊天界面布局和语音录制和播放功能,这里只实现了ios端的语音录制播放功能,android端没有测试。
注意事项:
ios端需要开启访问麦克风权限,位置在ios->Runner->Info.plist
<key>NSMicrophoneUsageDescription</key>
<string>访问麦克风</string>
用到的部分组件依赖及版本:
#语音录制、播放插件
flutter_sound: ^9.2.13
#检查权限
permission_handler: ^6.0.1
#此插件会告知操作系统您的音频应用程序的性质(例如游戏、媒体播放器、助手等)以及您的应用程序将如何处理和启动音频中断(例如电话中断)
audio_session: ^0.1.10
#uuid
uuid: ^3.0.6
遇到的坑
遇到的坑1
聊天消息布局不满一页在上方显示,满一页则停留在最底部:
解决方法
使用listview反转设置可以一直保持消息在底部,但是消息数据必须要倒序;
使用Container的向上居中可以使子元素撑不满一屏时向上显示。
遇到的坑2
在iPhoneX及所有刘海屏Bottom留白问题:
解决方法
使用SafeArea安全组件可解决此问题
遇到的坑3
IOS端在Xcode Build时报错:
Undefined symbols for architecture arm64:
"___gxx_personality_v0", referenced from:
+[FlutterSound registerWithRegistrar:] in flutter_sound(FlutterSound.o)
解决方法
在Xcode Build Setting中的Other Linker Flags添加-lc++即可
遇到的坑4
点击录音按钮不提示申请权限直接报错:
排查了好久原来是检查权限工具版本的bug,改为6.0.1可成功弹出权限申请
Fluuter语音录制及播放组件生命周期
Flutter录音组件生命周期图
Flutter语音播放组件生命周期图
代码
import 'dart:math';
import 'dart:ui';
import 'package:audio_session/audio_session.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:new_chat/code/message_type.dart';
import 'package:new_chat/r.dart';
import 'package:new_chat/service/screen_adapter.dart';
import 'package:new_chat/util/time_utils.dart';
import 'package:new_chat/widget/toast_widget.dart';
import 'package:flutter_sound/flutter_sound.dart';
import 'package:flutter_sound_platform_interface/flutter_sound_recorder_platform_interface.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:logger/logger.dart' show Level;
import 'package:uuid/uuid.dart';
class SingleChatPage extends StatefulWidget {
final String chatId;
const SingleChatPage({Key? key, required this.chatId}) : super(key: key);
@override
State<SingleChatPage> createState() {
return _SingleChatPageState();
}
}
class _SingleChatPageState extends State<SingleChatPage> {
//message data
List _messageData = [];
///语音播放及录制定义begin
//默认语音录制为关闭
bool _keyboardVoiceEnable = false;
//listview跳转控制器
final ScrollController _scrollController = ScrollController();
//消息文本控制器
final TextEditingController _textEditingController = TextEditingController();
//语音类型
final AudioSource _theSource = AudioSource.microphone;
//存储录音编码格式
Codec _codec = Codec.aacMP4;
//播放器权限
bool _voicePlayerIsInitialized = false;
//录制权限
bool _voiceRecorderIsInitialized = false;
//播放器是否可播放
bool _voicePlayerIsReady = false;
//播放器是否在播放
bool _voicePlayerIsPlay = false;
//语音播放工具
final FlutterSoundPlayer _voicePlayer =
FlutterSoundPlayer(logLevel: Level.error);
//语音录制工具
final FlutterSoundRecorder _voiceRecorder =
FlutterSoundRecorder(logLevel: Level.error);
//存储文件后缀
String _voiceFilePathSuffix = 'temp_file.mp4';
//录音文件存储前缀
String _voiceFilePrefix = "";
///语音播放及录制定义end
@override
void initState() {
_initMessageData();
//初始化播放器
_voicePlayer.openPlayer().then((value) {
setState(() {
_voicePlayerIsInitialized = true;
});
});
//初始化录音
_initVoiceRecorder().then((value) {
setState(() {
_voiceRecorderIsInitialized = true;
});
});
super.initState();
}
@override
void dispose() {
//关闭语音播放
_voicePlayer.closePlayer();
//关闭语音录制
_voiceRecorder.closeRecorder();
super.dispose();
}
///录音及语音方法定义begin
///初始录音
///todo 用户禁止语音权限提示
Future<void> _initVoiceRecorder() async {
if (!kIsWeb) {
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
throw RecordingPermissionException('Microphone permission not granted');
}
}
await _voiceRecorder.openRecorder();
if (!await _voiceRecorder.isEncoderSupported(_codec) && kIsWeb) {
_codec = Codec.opusWebM;
_voiceFilePathSuffix = 'tau_file.webm';
if (!await _voiceRecorder.isEncoderSupported(_codec) && kIsWeb) {
_voiceRecorderIsInitialized = true;
return;
}
}
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.spokenAudio,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
androidWillPauseWhenDucked: true,
));
_voiceRecorderIsInitialized = true;
}
///开始录音并返回录音文件前缀
String _beginVoice() {
if (!_voiceRecorderIsInitialized) {
ToastWidget.showToast("没有录音权限", ToastGravity.CENTER);
throw Exception("没有录音权限");
}
var uuid = const Uuid().v4();
_voiceRecorder
.startRecorder(
codec: _codec,
toFile: uuid + _voiceFilePathSuffix,
audioSource: _theSource)
.then((value) {
setState(() {
//播放按钮禁用并插入语音到消息中
_voicePlayerIsReady = false;
});
});
return uuid;
}
///停止录音 并将消息存储
void _stopVoice(String voiceFileId) async {
await _voiceRecorder.stopRecorder().then((value) {
setState(() {
//可以播放
_voicePlayerIsReady = true;
Map data = {};
data['messageId'] = voiceFileId;
//todo 差语音时长
data['message'] = "语音消息按钮...";
data['messageType'] = MessageType.voice;
data['messageTime'] = TimeUtils.getFormatDataString(
DateTime.now(), "yyyy-MM-dd HH:mm:ss");
data['isMe'] = Random.secure().nextBool();
//存储路径
data['messageVoice'] = voiceFileId + _voiceFilePathSuffix;
_messageData.insert(0, data);
});
});
}
///开始播放录音
void _beginPlayer(String messageVoiceFilePath) {
assert(_voicePlayerIsInitialized &&
_voicePlayerIsReady &&
_voiceRecorder.isStopped &&
_voicePlayer.isStopped);
_voicePlayer
.startPlayer(
fromURI: messageVoiceFilePath,
//codec: kIsWeb ? Codec.opusWebM : Codec.aacADTS,
//语音播放完后的动作->停止播放
whenFinished: () {
setState(() {
print("播放完的动作");
_voicePlayerIsPlay = false;
_voicePlayerIsReady = true;
});
})
.then((value) {
//语音正在播放的动作->正在播放
setState(() {
print("语音正在播放的动作");
_voicePlayerIsPlay = true;
_voicePlayerIsReady = false;
});
});
}
///停止播放声音
void _stopPlayer() {
_voicePlayer.stopPlayer().then((value) {
setState(() {
_voicePlayerIsReady = true;
_voicePlayerIsPlay = false;
});
});
}
///录音及语音方法定义end
///初始化聊天数据
//todo 差网络请求聊天数据 这里暂时mock
_initMessageData() async {
Dio dio = Dio();
//mock data
try {
//todo timeout 1 seconds
var response = await dio
.get(
"http://192.168.10.15:3000/mock/313/message",
)
.timeout(const Duration(seconds: 1));
setState(() {
_messageData = response.data['data'];
});
} catch (e) {
//mock test data
List<Map> tempData = [];
Map data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "嗯,没问题。明天我起床就联系你。";
data['isMe'] = false;
data['messageTime'] = "2022-08-17 16:20:20";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "好的。有什么事情及时联系我都在线的。";
data['isMe'] = true;
data['messageTime'] = "2022-08-17 16:19:20";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "晚安!";
data['isMe'] = false;
data['messageTime'] = "2022-08-17 16:16:20";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "嗯,今晚好好休息!";
data['isMe'] = false;
data['messageTime'] = "2022-08-17 16:15:20";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "好的,那到时见!!!";
data['isMe'] = true;
data['messageTime'] = "2022-08-16 01:15:20";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "不用准备什么东西,我都已经准备好了。应该是吃完午餐就出发吧。大概下午2点左右。";
data['isMe'] = false;
data['messageTime'] = "2022-08-16 01:13:20";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "需要准备什么东西带过去";
data['isMe'] = true;
data['messageTime'] = "2022-08-15 12:42:20";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "好的,10点左右可以的。你打算几点出发?";
data['isMe'] = true;
data['messageTime'] = "2022-08-14 14:24:20";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "嗯,大概上午10点左右吧。 如果没空就下午。";
data['isMe'] = false;
data['messageTime'] = "2022-08-13 11:11:22";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "明天什么时候呢???";
data['isMe'] = true;
data['messageTime'] = "2022-08-12 10:32:11";
data['messageType'] = MessageType.text;
tempData.add(data);
data = {};
data['messageId'] = const Uuid().v4();
data['message'] = "你明天有空过来吗??";
data['isMe'] = false;
data['messageTime'] = "2022-08-12 10:31:24";
data['messageType'] = MessageType.text;
tempData.add(data);
setState(() {
_messageData = tempData;
});
}
}
// chat widget
//todo 后期需要根据messageSendType区分
Widget _chatWidget(Map data) {
if (data['isMe']) {
return _myMessageWidget(data);
}
return _yourMessageWidget(data);
}
//your message widget
Widget _yourMessageWidget(Map data) {
String messageType = data['messageType'];
return Padding(
padding: EdgeInsets.fromLTRB(ScreenAdapter.width(32),
ScreenAdapter.height(20), 0, ScreenAdapter.height(20)),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
constraints: BoxConstraints(
maxWidth: ScreenAdapter.width(450),
),
padding: EdgeInsets.fromLTRB(
ScreenAdapter.width(20),
ScreenAdapter.height(24),
ScreenAdapter.width(20),
ScreenAdapter.height(24)),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(ScreenAdapter.width(20)),
color: const Color.fromRGBO(255, 255, 255, 1),
boxShadow: const [
BoxShadow(
color: Color.fromRGBO(0, 0, 0, 0.07),
offset: Offset(0, 4),
blurRadius: 8,
spreadRadius: 0)
]),
child: messageType == MessageType.text
? Text(
data['message'],
style: TextStyle(
color: const Color.fromRGBO(51, 51, 51, 1),
fontSize: ScreenAdapter.size(28)),
)
//todo 差语音样式
: GestureDetector(
onTap: () {
//如果可播放且没有在播放则播放
if (_voicePlayerIsReady && !_voicePlayerIsPlay) {
_beginPlayer(data['messageVoice']);
}
//如果可播放且在播放 则停止播放
if (_voicePlayerIsReady && _voicePlayerIsPlay) {
_stopPlayer();
}
},
child: Text(
"语音消息....",
style: TextStyle(
color: const Color.fromRGBO(51, 51, 51, 1),
fontSize: ScreenAdapter.size(28)),
),
),
),
Padding(
padding: EdgeInsets.only(left: ScreenAdapter.width(20)),
child: Text(
TimeUtils.setMessageTime(data['messageTime']),
style: TextStyle(
color: const Color.fromRGBO(183, 183, 183, 1),
fontSize: ScreenAdapter.size(20),
fontWeight: FontWeight.w500),
),
)
],
),
);
}
//my message widget
Widget _myMessageWidget(Map data) {
String messageType = data['messageType'];
return Padding(
padding: EdgeInsets.fromLTRB(0, ScreenAdapter.height(20),
ScreenAdapter.width(32), ScreenAdapter.height(20)),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.only(right: ScreenAdapter.width(20)),
child: Text(
TimeUtils.setMessageTime(data['messageTime']),
style: TextStyle(
color: const Color.fromRGBO(183, 183, 183, 1),
fontSize: ScreenAdapter.size(20),
fontWeight: FontWeight.w500),
),
),
Container(
constraints: BoxConstraints(
maxWidth: ScreenAdapter.width(450),
),
//message
padding: EdgeInsets.fromLTRB(
ScreenAdapter.width(20),
ScreenAdapter.height(24),
ScreenAdapter.width(20),
ScreenAdapter.height(24)),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(ScreenAdapter.width(20)),
gradient: const LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Color.fromRGBO(99, 133, 230, 1),
Color.fromRGBO(179, 106, 232, 1),
],
),
boxShadow: const [
BoxShadow(
color: Color.fromRGBO(111, 129, 230, 0.2),
offset: Offset(0, 4),
blurRadius: 8,
spreadRadius: 0)
]),
//todo 后期要使用switch 这里先解决文本和语音
child: messageType == MessageType.text
? Text(
data['message'],
style: TextStyle(
color: const Color.fromRGBO(255, 255, 255, 1),
fontSize: ScreenAdapter.size(28)),
)
//todo 差语音样式
: GestureDetector(
onTap: () {
//如果可播放且没有在播放则播放
if (_voicePlayerIsReady && !_voicePlayerIsPlay) {
_beginPlayer(data['messageVoice']);
}
//如果可播放且在播放 则停止播放
if (_voicePlayerIsReady && _voicePlayerIsPlay) {
_stopPlayer();
}
},
child: Text(
"语音消息....",
style: TextStyle(
color: const Color.fromRGBO(255, 255, 255, 1),
fontSize: ScreenAdapter.size(28)),
),
),
),
],
));
}
@override
Widget build(BuildContext context) {
ScreenAdapter.init(context);
return Scaffold(
body: Column(
children: [
//head
Container(
height: ScreenAdapter.height(220),
width: ScreenAdapter.width(750),
//padding only top->status bar
padding: EdgeInsets.only(
top: MediaQueryData.fromWindow(window).padding.top),
//setting LinearGradient
decoration: const BoxDecoration(
boxShadow: [
BoxShadow(
offset: Offset(0, 8),
blurRadius: 28,
spreadRadius: 0,
color: Color.fromRGBO(60, 70, 74, 0.3),
)
],
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Color.fromRGBO(99, 133, 230, 1),
Color.fromRGBO(179, 106, 232, 1),
],
)),
//head widget
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
//left row
Row(
children: [
//break menu
Container(
margin: EdgeInsets.only(left: ScreenAdapter.width(44)),
child: InkWell(
onTap: () {
Navigator.pop(context);
},
child: Image.asset(R.assetsImgLeftMenu,
height: ScreenAdapter.height(42),
width: ScreenAdapter.width(25)),
),
),
//user portrait
Container(
margin: EdgeInsets.only(left: ScreenAdapter.width(27)),
child: ClipOval(
child: Image.network(
"https://img2.baidu.com/it/u=2518930323,4285282159&fm=253&fmt=auto&app=120&f=JPEG?w=800&h=800",
width: ScreenAdapter.width(80),
height: ScreenAdapter.height(80),
fit: BoxFit.cover,
),
),
)
],
),
//center column
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Shakibul Islam",
style: TextStyle(
color: const Color.fromRGBO(255, 255, 255, 1),
fontSize: ScreenAdapter.size(32))),
Text("最近会话 8:00",
style: TextStyle(
color: const Color.fromRGBO(255, 255, 255, 1),
fontSize: ScreenAdapter.size(24))),
],
),
//right row
Row(
children: [
Container(
margin: EdgeInsets.only(right: ScreenAdapter.width(40)),
width: ScreenAdapter.width(38),
height: ScreenAdapter.height(24),
child: Image.asset(R.assetsImgChatVideo),
),
Container(
margin: EdgeInsets.only(right: ScreenAdapter.width(40)),
child: Image.asset(R.assetsImgChatPhone,
width: ScreenAdapter.width(32),
height: ScreenAdapter.height(32)),
),
Container(
margin: EdgeInsets.only(right: ScreenAdapter.width(48)),
child: Image.asset(R.assetsImgChatGroup,
width: ScreenAdapter.width(8),
height: ScreenAdapter.height(36)),
)
],
)
],
),
),
//chat listview
//todo 差消息撤回、删除、多选删除
Expanded(
flex: 1,
child: Container(
alignment: Alignment.topCenter,
color: const Color.fromRGBO(244, 243, 249, 1),
child: MediaQuery.removePadding(
removeTop: true,
removeBottom: true,
context: context,
child: ListView.builder(
shrinkWrap: true,
reverse: true,
controller: _scrollController,
itemCount: _messageData.length,
itemBuilder: (BuildContext context, int index) {
return _chatWidget(_messageData[index]);
}),
)),
),
//bottom TextField
//set bottom color
ColoredBox(
color: const Color.fromRGBO(244, 243, 249, 0.5),
child: SafeArea(
top: false,
left: false,
right: false,
// maintainBottomViewPadding: true,
child: Padding(
padding: EdgeInsets.fromLTRB(
ScreenAdapter.width(32),
ScreenAdapter.height(20),
ScreenAdapter.width(25),
ScreenAdapter.height(20)),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//TextField SizedBox
SizedBox(
width: ScreenAdapter.width(484),
//TextField BoxDecoration
child: TextField(
minLines: 1,
maxLines: 3,
//发送按钮
textInputAction: TextInputAction.send,
controller: _textEditingController,
//键盘弹起聊天页面滚到底部
onTap: () {
_scrollController.jumpTo(0);
},
onSubmitted: (String str) {
_textEditingController.clear();
if (str.isNotEmpty) {
setState(() {
Map data = {};
data['messageId'] = const Uuid().v4();
data['messageType'] = MessageType.text;
data['message'] = str;
data['messageTime'] =
TimeUtils.getFormatDataString(
DateTime.now(),
"yyyy-MM-dd HH:mm:ss");
data['isMe'] = Random.secure().nextBool();
_messageData.insert(0, data);
});
}
},
//带外边框的样式
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
hintText: "输入信息......",
contentPadding: const EdgeInsets.all(10),
suffixIcon: GestureDetector(
//长摁录音
onLongPress: () {
setState(() {
_keyboardVoiceEnable =
!_keyboardVoiceEnable;
});
//调取录音方法
if (_voicePlayerIsInitialized) {
_voiceFilePrefix = _beginVoice();
}
},
onLongPressUp: () async {
//停止录音并写入消息
if (_voicePlayerIsInitialized) {
_stopVoice(_voiceFilePrefix);
}
setState(() {
_keyboardVoiceEnable =
!_keyboardVoiceEnable;
});
//jumpToBottom
_scrollController.jumpTo(0);
},
child: Icon(
Icons.keyboard_voice,
color: _keyboardVoiceEnable
? Colors.blue
: Colors.black26,
),
),
//获得焦点时的边框样式
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(
ScreenAdapter.width(40))),
borderSide: BorderSide(
color: const Color.fromRGBO(
99, 133, 230, 1),
width: ScreenAdapter.width(4))),
//允许编辑焦点时的边框样式
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(
ScreenAdapter.width(40))),
borderSide: BorderSide(
color: const Color.fromRGBO(
99, 133, 230, 1),
width: ScreenAdapter.width(4)))),
)),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: const [
//todo 差相机按钮事件
Icon(
Icons.camera_alt_outlined,
color: Color.fromRGBO(164, 175, 207, 1),
),
//todo 差相册按钮事件
Icon(
Icons.photo,
color: Color.fromRGBO(164, 175, 207, 1),
),
//todo 还没想好
Icon(
Icons.add_circle,
color: Colors.blue,
),
],
))
],
),
)),
)
//chat widget
//chat list
],
),
);
}
}
简单视频演示:
Flutter简单聊天界面布局及语音录制播放配套视频