基于声网 Flutter SDK 实现互动直播
前言
互动直播是实现很多热门场景的基础,例如直播带货、秀场直播,还有类似抖音的直播 PK等。本文是由声网社区的开发者“小猿”撰写的Flutter基础教程系列中的第二篇,他将带着大家用一个小时,利用声网 Flutter SDK 实现视频直播、发评论、送礼物等基础功能。
开发一个跨平台的的直播的功能需要多久?如果直播还需要支持各种互动效果呢?
我给出的答案是不到一个小时,在 Flutter + 声网 SDK 的加持下,你可以在一个小时之内就完成一个互动直播的雏形。
声网作为最早支持 Flutter 平台的 SDK 厂商之一, 其 RTC SDK 实现主要来自于封装好的 C/C++ 等 native 代码,而这些代码会被打包为对应平台的动态链接库,最后通过 Dart 的 FFI(ffigen) 进行封装调用,减少了 Flutter 和原生平台交互时在 Channel 上的性能开销。
开始之前
接下来让我们进入正题,既然选择了 Flutter + 声网的实现路线,那么在开始之前肯定有一些需要准备的前置条件,首先是为了满足声网 RTC SDK 的使用条件,开发环境必须为:
- Flutter 2.0 或更高版本
- Dart 2.14.0 或更高版本
从目前 Flutter 和 Dart 版本来看,上面这个要求并不算高,然后就是你需要注册一个声网开发者账号 ,从而获取后续配置所需的 App ID 和 Token 等配置参数。
如果对于配置“门清”,可以忽略跳过这部分直接看下一章节。
创建项目
首先可以在声网控制台的项目管理页面上点击创建项目,然后在弹出框选输入项目名称,之后选择「互动直播」场景和「安全模式(APP ID + Token)」 即可完成项目创建。
根据法规,创建项目需要实名认证,这个必不可少,另外使用场景不必太过纠结,项目创建之后也是可以根据需要自己修改。
获取 App ID
在项目列表点击创建好的项目配置,进入项目详情页面之后,会看到基本信息栏目有个 App ID 的字段,点击如下图所示图标,即可获取项目的 App ID。
App ID 也算是敏感信息之一,所以尽量妥善保存,避免泄密。
获取 Token
为提高项目的安全性,声网推荐了使用 Token 对加入频道的用户进行鉴权,在生产环境中,一般为保障安全,是需要用户通过自己的服务器去签发 Token,而如果是测试需要,可以在项目详情页面的「临时 token 生成器」获取临时 Token:
在频道名输入一个临时频道,比如 Test2 ,然后点击生成临时 token 按键,即可获取一个临时 Token,有效期为 24 小时。
这里得到的 Token 和频道名就可以直接用于后续的测试,如果是用在生产环境上,建议还是在服务端签发 Token ,签发 Token 除了 App ID 还会用到 App 证书,获取 App 证书同样可以在项目详情的应用配置上获取。
更多服务端签发 Token 可见 token server 文档 。
开始开发
通过前面的配置,我们现在拥有了 App ID、 频道名和一个有效的临时 Token ,接下里就是在 Flutter 项目里引入声网的 RTC SDK :agora_rtc_engine 。
项目配置
首先在 Flutter 项目的 pubspec.yaml文件中添加以下依赖,其中 agora_rtc_engine 这里引入的是**6.1.0 **版本 。
其实 permission_handler 并不是必须的,只是因为视频通话项目必不可少需要申请到麦克风和相机权限,所以这里推荐使用 permission_handler来完成权限的动态申请。
dependencies:
flutter:
sdk: flutter
agora_rtc_engine: ^6.1.0
permission_handler: ^10.2.0
这里需要注意的是, Android 平台不需要特意在主工程的 AndroidManifest.xml文件上添加uses-permission ,因为 SDK 的 AndroidManifest.xml 已经添加过所需的权限。
iOS和macOS可以直接在Info.plist文件添加NSCameraUsageDescription和NSCameraUsageDescription的权限声明,或者在 Xcode 的 Info 栏目添加Privacy - Microphone Usage Description和Privacy - Camera Usage Description。
<key>NSCameraUsageDescription</key>
<string>*****</string>
<key>NSMicrophoneUsageDescription</key>
<string>*****</string>
使用声网 SDK
获取权限
在正式调用声网 SDK 的 API 之前,首先我们需要申请权限,如下代码所示,可以使用permission_handler的request提前获取所需的麦克风和摄像头权限。
@override
void initState() {
super.initState();
_requestPermissionIfNeed();
}
Future<void> _requestPermissionIfNeed() async {
await [Permission.microphone, Permission.camera].request();
}
因为是测试项目,默认我们可以在应用首页就申请获得。
初始化引擎
接下来开始配置 RTC 引擎,如下代码所示,通过 import 对应的 dart 文件之后,就可以通过 SDK 自带的 createAgoraRtcEngine 方法快速创建引擎,然后通过 initialize方法就可以初始化 RTC 引擎了,可以看到这里会用到前面创建项目时得到的 App ID 进行初始化。
注意这里需要在请求完权限之后再初始化引擎。
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
late final RtcEngine _engine;
Future<void> _initEngine() async {
_engine = createAgoraRtcEngine();
await _engine.initialize(const RtcEngineContext(
appId: appId,
));
···
}
接着我们需要通过 registerEventHandler注册一系列回调方法,在 RtcEngineEventHandler 里有很多回调通知,而一般情况下我们比如常用到的会是下面这几个:
- onError :判断错误类型和错误信息
- onJoinChannelSuccess:加入频道成功
- onUserJoined:有用户加入了频道
- onUserOffline:有用户离开了频道
- onLeaveChannel:离开频道
- onStreamMessage: 用于接受远端用户发送的消息
Future<void> _initEngine() async {
···
_engine.registerEventHandler(RtcEngineEventHandler(
onError: (ErrorCodeType err, String msg) {},
onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
setState(() {
isJoined = true;
});
},
onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
remoteUid.add(rUid);
setState(() {});
},
onUserOffline:
(RtcConnection connection, int rUid, UserOfflineReasonType reason) {
setState(() {
remoteUid.removeWhere((element) => element == rUid);
});
},
onLeaveChannel: (RtcConnection connection, RtcStats stats) {
setState(() {
isJoined = false;
remoteUid.clear();
});
},
onStreamMessage: (RtcConnection connection, int remoteUid, int streamId,
Uint8List data, int length, int sentTs) {
}));
用户可以根据上面的回调来判断 UI 状态,比如当前用户时候处于频道内显示对方的头像和数据,提示用户进入直播间,接收观众发送的消息等。
接下来因为我们的需求是「互动直播」,所以就会有观众和主播的概念,所以如下代码所示:
- 首先需要调用enableVideo 打开视频模块支持,可以看到视频画面
- 同时我们还可以对视频编码进行一些简单配置,比如通过
VideoEncoderConfiguration 配置分辨率是帧率 - 根据进入用户的不同,我们假设type为"Create"是主播, "Join"是观众
- 那么初始化时,主播需要通过通过startPreview开启预览
- 观众需要通过enableLocalAudio(false); 和enableLocalVideo(false);关闭本地的音视频效果
Future<void> _initEngine() async {
···
_engine.enableVideo();
await _engine.setVideoEncoderConfiguration(
const VideoEncoderConfiguration(
dimensions: VideoDimensions(width: 640, height: 360),
frameRate: 15,
),
);
/// 自己直播才需要预览
if (widget.type == "Create") {
await _engine.startPreview();
}
if (widget.type != "Create") {
_engine.enableLocalAudio(false);
_engine.enableLocalVideo(false);
}
关于 setVideoEncoderConfiguration 的更多参数配置支持如下所示:
接下来需要初始化一个 VideoViewController,根据角色的不同:
- 主播可以通过VideoViewController直接构建控制器,因为画面是通过主播本地发出的流
- 观众需要通过VideoViewController.remote构建,因为观众需要获取的是主播的信息流,区别在于多了connection 参数需要写入channelId,同时VideoCanvas需要写入主播的uid 才能获取到画面
late VideoViewController rtcController;
Future<void> _initEngine() async {
···
rtcController = widget.type == "Create"
? VideoViewController(
rtcEngine: _engine,
canvas: const VideoCanvas(uid: 0),
)
: VideoViewController.remote(
rtcEngine: _engine,
connection: const RtcConnection(channelId: cid),
canvas: VideoCanvas(uid: widget.remoteUid),
);
setState(() {
_isReadyPreview = true;
});
最后调用 joinChannel加入直播间就可以了,其中这些参数都是必须的:
- token 就是前面临时生成的Token
- channelId 就是前面的渠道名
- uid 就是当前用户的id ,这些id 都是我们自己定义的
- channelProfile根据角色我们可以选择不同的类别,比如主播因为是发起者,可以选择channelProfileLiveBroadcasting ;而观众选channelProfileCommunication
- clientRoleType选择clientRoleBroadcaster
Future<void> _initEngine() async {
···
await _joinChannel();
}
Future<void> _joinChannel() async {
await _engine.joinChannel(
token: token,
channelId: cid,
uid: widget.uid,
options: ChannelMediaOptions(
channelProfile: widget.type == "Create"
? ChannelProfileType.channelProfileLiveBroadcasting
: ChannelProfileType.channelProfileCommunication,
clientRoleType: ClientRoleType.clientRoleBroadcaster,
// clientRoleType: widget.type == "Create"
// ? ClientRoleType.clientRoleBroadcaster
// : ClientRoleType.clientRoleAudience,
),
);
之前我以为观众可以选择 clientRoleAudience 角色,但是后续发现如果用户是通过 clientRoleAudience 加入可以直播间,onUserJoined 等回调不会被触发,这会影响到我们后续的开发,所以最后还是选择了 clientRoleBroadcaster。
渲染画面
接下来就是渲染画面,如下代码所示,在 UI 上加入 AgoraVideoView控件,并把上面初始化成功的RtcEngine和VideoViewController配置到 AgoraVideoView,就可以完成画面预览。
Stack(
children: [
AgoraVideoView(
controller: rtcController,
),
Align(
alignment: const Alignment(-.95, -.95),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.of(remoteUid.map(
(e) => Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
shape: BoxShape.circle, color: Colors.blueAccent),
alignment: Alignment.center,
child: Text(
e.toString(),
style: const TextStyle(
fontSize: 10, color: Colors.white),
),
),
)),
),
),
),
这里还在页面顶部增加了一个 SingleChildScrollView ,把直播间里的观众 id 绘制出来,展示当前有多少观众在线。
接着我们只需要在做一些简单的配置,就可以完成一个简单直播 Demo 了,如下图所示,在主页我们提供 Create 和 Join 两种角色进行选择,并且模拟用户的 uid 来进入直播间:
- 主播只需要输入自己的 uid 即可开播
- 观众需要输入自己的 uid 的同时,也输入主播的 uid ,这样才能获取到主播的画面
接着我们只需要通过 Navigator.push 打开页面,就可以看到主播(左)成功开播后,观众(右)进入直播间的画面效果了,这时候如果你看下方截图,可能会发现观众和主播的画面是镜像相反的。
如果想要主播和观众看到的画面是一致的话,可以在前面初始化代码的 VideoEncoderConfiguration 里配置 mirrorMode 为 videoMirrorModeEnabled,就可以让主播画面和观众一致。
await _engine.setVideoEncoderConfiguration(
const VideoEncoderConfiguration(
dimensions: VideoDimensions(width: 640, height: 360),
frameRate: 15,
bitrate: 0,
mirrorMode: VideoMirrorModeType.videoMirrorModeEnabled,
),
);
这里 mirrorMode 配置不需要区分角色,因为 mirrorMode 参数只会只影响远程用户看到的视频效果。
上面动图左下角还有一个观众进入直播间时的提示效果,这是根据 onUserJoined 回调实现,在收到用户进入直播间后,将 id 写入数组,并通过PageView进行轮循展示后移除。
互动开发
前面我们已经完成了直播的简单 Demo 效果,接下来就是实现「互动」的思路了。
前面我们初始化时注册了一个 onStreamMessage 的回调,可以用于主播和观众之间的消息互动,那么接下来主要通过两个「互动」效果来展示如果利用声网 SDK 实现互动的能力。
首先是「消息互动」:
- 我们需要通过 SDK 的createDataStream 方法得到一个streamId
- 然后把要发送的文本内容转为Uint8List
- 最后利用sendStreamMessage 就可以结合streamId 就可以将内容发送到直播间
streamId = await _engine.createDataStream(
const DataStreamConfig(syncWithAudio: false, ordered: false));
final data = Uint8List.fromList(
utf8.encode(messageController.text));
await _engine.sendStreamMessage(
streamId: streamId, data: data, length: data.length);
在 onStreamMessage 里我们可以通过utf8.decode(data) 得到用户发送的文本内容,结合收到的用户 id ,根据内容,我们就可以得到如下图所示的互动消息列表。
onStreamMessage: (RtcConnection connection, int remoteUid, int streamId,
Uint8List data, int length, int sentTs) {
var message = utf8.decode(data);
doMessage(remoteUid, message);
}));
前面显示的 id ,后面对应的是用户发送的文本内容
那么我们再进阶一下,收到用户一些「特殊格式消息」之后,我们可以展示动画效果而不是文本内容,例如:
在收到 [ *** ] 格式的消息时弹出一个动画,类似粉丝送礼。
实现这个效果我们可以引入第三方 rive 动画库,这个库只要通过 RiveAnimation.network 就可以实现远程加载,这里我们直接引用一个社区开放的免费 riv 动画,并且在弹出后 3s 关闭动画。
showAnima() {
showDialog(
context: context,
builder: (context) {
return const Center(
child: SizedBox(
height: 300,
width: 300,
child: RiveAnimation.network(
'https://public.rive.app/community/runtime-files/4037-8438-first-animation.riv',
),
),
);
},
barrierColor: Colors.black12);
Future.delayed(const Duration(seconds: 3), () {
Navigator.of(context).pop();
});
}
最后,我们通过一个简单的正则判断,如果收到 [ *** ] 格式的消息就弹出动画,如果是其他就显示文本内容,最终效果如下图动图所示。
bool isSpecialMessage(message) {
RegExp reg = RegExp(r"[*]$");
return reg.hasMatch(message);
}
doMessage(int id, String message) {
if (isSpecialMessage(message) == true) {
showAnima();
} else {
normalMessage(id, message);
}
}
虽然代码并不十分严谨,但是他展示了如果使用声网 SDK 实现 「互动」的效果,可以看到使用声网 SDK 只需要简单配置就能完成「直播」和 「互动」两个需求场景。
完整代码如下所示,这里面除了声网 SDK 还引入了另外两个第三方包:
- flutter_swiper_view 实现用户进入时的循环播放提示
- rive用于上面我们展示的动画效果
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/material.dart';
import 'package:flutter_swiper_view/flutter_swiper_view.dart';
import 'package:rive/rive.dart';
const token = "xxxxxx";
const cid = "test";
const appId = "xxxxxx";
class LivePage extends StatefulWidget {
final int uid;
final int? remoteUid;
final String type;
const LivePage(
{required this.uid, required this.type, this.remoteUid, Key? key})
: super(key: key);
@override
State<StatefulWidget> createState() => _State();
}
class _State extends State<LivePage> {
late final RtcEngine _engine;
bool _isReadyPreview = false;
bool isJoined = false;
Set<int> remoteUid = {};
final List<String> _joinTip = [];
List<Map<int, String>> messageList = [];
final messageController = TextEditingController();
final messageListController = ScrollController();
late VideoViewController rtcController;
late int streamId;
final animaStream = StreamController<String>();
@override
void initState() {
super.initState();
animaStream.stream.listen((event) {
showAnima();
});
_initEngine();
}
@override
void dispose() {
super.dispose();
animaStream.close();
_dispose();
}
Future<void> _dispose() async {
await _engine.leaveChannel();
await _engine.release();
}
Future<void> _initEngine() async {
_engine = createAgoraRtcEngine();
await _engine.initialize(const RtcEngineContext(
appId: appId,
));
_engine.registerEventHandler(RtcEngineEventHandler(
onError: (ErrorCodeType err, String msg) {},
onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
setState(() {
isJoined = true;
});
},
onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
remoteUid.add(rUid);
var tip = (widget.type == "Create")
? "$rUid 来了"
: "${connection.localUid} 来了";
_joinTip.add(tip);
Future.delayed(const Duration(milliseconds: 1500), () {
_joinTip.remove(tip);
setState(() {});
});
setState(() {});
},
onUserOffline:
(RtcConnection connection, int rUid, UserOfflineReasonType reason) {
setState(() {
remoteUid.removeWhere((element) => element == rUid);
});
},
onLeaveChannel: (RtcConnection connection, RtcStats stats) {
setState(() {
isJoined = false;
remoteUid.clear();
});
},
onStreamMessage: (RtcConnection connection, int remoteUid, int streamId,
Uint8List data, int length, int sentTs) {
var message = utf8.decode(data);
doMessage(remoteUid, message);
}));
_engine.enableVideo();
await _engine.setVideoEncoderConfiguration(
const VideoEncoderConfiguration(
dimensions: VideoDimensions(width: 640, height: 360),
frameRate: 15,
bitrate: 0,
mirrorMode: VideoMirrorModeType.videoMirrorModeEnabled,
),
);
/// 自己直播才需要预览
if (widget.type == "Create") {
await _engine.startPreview();
}
await _joinChannel();
if (widget.type != "Create") {
_engine.enableLocalAudio(false);
_engine.enableLocalVideo(false);
}
rtcController = widget.type == "Create"
? VideoViewController(
rtcEngine: _engine,
canvas: const VideoCanvas(uid: 0),
)
: VideoViewController.remote(
rtcEngine: _engine,
connection: const RtcConnection(channelId: cid),
canvas: VideoCanvas(uid: widget.remoteUid),
);
setState(() {
_isReadyPreview = true;
});
}
Future<void> _joinChannel() async {
await _engine.joinChannel(
token: token,
channelId: cid,
uid: widget.uid,
options: ChannelMediaOptions(
channelProfile: widget.type == "Create"
? ChannelProfileType.channelProfileLiveBroadcasting
: ChannelProfileType.channelProfileCommunication,
clientRoleType: ClientRoleType.clientRoleBroadcaster,
// clientRoleType: widget.type == "Create"
// ? ClientRoleType.clientRoleBroadcaster
// : ClientRoleType.clientRoleAudience,
),
);
streamId = await _engine.createDataStream(
const DataStreamConfig(syncWithAudio: false, ordered: false));
}
bool isSpecialMessage(message) {
RegExp reg = RegExp(r"[*]$");
return reg.hasMatch(message);
}
doMessage(int id, String message) {
if (isSpecialMessage(message) == true) {
animaStream.add(message);
} else {
normalMessage(id, message);
}
}
normalMessage(int id, String message) {
messageList.add({id: message});
setState(() {});
Future.delayed(const Duration(seconds: 1), () {
messageListController
.jumpTo(messageListController.position.maxScrollExtent + 2);
});
}
showAnima() {
showDialog(
context: context,
builder: (context) {
return const Center(
child: SizedBox(
height: 300,
width: 300,
child: RiveAnimation.network(
'https://public.rive.app/community/runtime-files/4037-8438-first-animation.riv',
),
),
);
},
barrierColor: Colors.black12);
Future.delayed(const Duration(seconds: 3), () {
Navigator.of(context).pop();
});
}
@override
Widget build(BuildContext context) {
if (!_isReadyPreview) return Container();
return Scaffold(
appBar: AppBar(
title: const Text("LivePage"),
),
body: Column(
children: [
Expanded(
child: Stack(
children: [
AgoraVideoView(
controller: rtcController,
),
Align(
alignment: const Alignment(-.95, -.95),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.of(remoteUid.map(
(e) => Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
shape: BoxShape.circle, color: Colors.blueAccent),
alignment: Alignment.center,
child: Text(
e.toString(),
style: const TextStyle(
fontSize: 10, color: Colors.white),
),
),
)),
),
),
),
Align(
alignment: Alignment.bottomLeft,
child: Container(
height: 200,
width: 150,
decoration: const BoxDecoration(
borderRadius:
BorderRadius.only(topRight: Radius.circular(8)),
color: Colors.black12,
),
padding: const EdgeInsets.only(left: 5, bottom: 5),
child: Column(
children: [
Expanded(
child: ListView.builder(
controller: messageListController,
itemBuilder: (context, index) {
var item = messageList[index];
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.keys.toList().toString(),
style: const TextStyle(
fontSize: 12, color: Colors.white),
),
const SizedBox(
width: 10,
),
Expanded(
child: Text(
item.values.toList()[0],
style: const TextStyle(
fontSize: 12, color: Colors.white),
),
)
],
),
);
},
itemCount: messageList.length,
),
),
Container(
height: 40,
color: Colors.black54,
padding: const EdgeInsets.only(left: 10),
child: Swiper(
itemBuilder: (context, index) {
return Container(
alignment: Alignment.centerLeft,
child: Text(
_joinTip[index],
style: const TextStyle(
color: Colors.white, fontSize: 14),
),
);
},
autoplayDelay: 1000,
physics: const NeverScrollableScrollPhysics(),
itemCount: _joinTip.length,
autoplay: true,
scrollDirection: Axis.vertical,
),
),
],
),
),
)
],
),
),
Container(
height: 80,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
Expanded(
child: TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
controller: messageController,
keyboardType: TextInputType.number),
),
TextButton(
onPressed: () async {
if (isSpecialMessage(messageController.text) != true) {
messageList.add({widget.uid: messageController.text});
}
final data = Uint8List.fromList(
utf8.encode(messageController.text));
await _engine.sendStreamMessage(
streamId: streamId, data: data, length: data.length);
messageController.clear();
setState(() {});
// ignore: use_build_context_synchronously
FocusScope.of(context).requestFocus(FocusNode());
},
child: const Text("Send"))
],
),
),
],
),
);
}
}
总结
从上面可以看到,其实跑完基础流程很简单,回顾一下前面的内容,总结下来就是:
- 申请麦克风和摄像头权限
- 创建和通过App ID初始化引擎
- 注册RtcEngineEventHandler回调用于判断状态和接收互动能力
- 根绝角色打开和配置视频编码支持
- 调用joinChannel加入直播间
- 通过AgoraVideoView和VideoViewController用户画面
- 通过engine创建和发送stream消息
从申请账号到开发 Demo ,利用声网的 SDK 开发一个「互动直播」从需求到实现大概只过了一个小时,虽然上述实现的功能和效果还很粗糙,但是主体流程很快可以跑通了。
欢迎开发者们也尝试体验声网 SDK,实现实时音视频互动场景。现注册声网账号下载 SDK,可获得每月免费 10000 分钟使用额度。如在开发过程中遇到疑问,可在声网开发者社区与官方工程师交流。
同时在 Flutter 的加持下,代码可以在移动端和 PC 端得到复用,这对于有音视频需求的中小型团队来说无疑是最优组合之一。
基于声网 Flutter SDK 实现互动直播的更多相关文章
- 基于Live555,ffmpeg的RTSP播放器直播与点播
基于Live555,ffmpeg的RTSP播放器直播与点播 多路RTSP高清视频播放器下载地址:http://download.csdn.net/detail/u011352914/6604437多路 ...
- 从Html5直播到互动直播,看直播协议的选择
目前,国内主流的直播协议有HLS.RTMP.HTTP FLV,适用于不同的直播场景. 一.HLS.RTMP与HTTP FLV 1.HLS HLS 全称是 HTTP Live Streaming, 是一 ...
- Flutter SDK安装(windows)
Flutter集成了Dart,因此不需要单独安装dart-sdk.Flutter的SDK可以从官网下载:https://flutter.io/sdk-archive/#windows 在Flutter ...
- 基于Unity3D 的Vuforia SDK开发基础教程
最新博客地址已转到: http://blog.csdn.net/zzlyw?viewmode=contents ------------------------------------------ ...
- 基于.NET的微信SDK
超级懒汉编写的基于.NET的微信SDK 一.前言 特别不喜欢麻烦的一个人,最近碰到了微信开发.下载下来了一些其他人写的微信开发“框架”,但是被恶心到了,实现的太臃肿啦. 最不喜欢的就是把微信返回的 ...
- 基于Petri网的工作流分析和移植
基于Petri网的工作流分析和移植 一.前言 在实际应用场景,包括PEC的订单流程从下订单到订单派送一直到订单完成都是按照一系列预先规定好的工作流策略进行的. 通常情况下如果是采用面向过程的编程方法, ...
- 颤振错误:当前Flutter SDK版本为2.1.0-dev.0.0.flutter-be6309690f?
我刚刚升级了我的扑动,升级后我无法在Android Studio上运行任何扑动项目.我收到此错误消息. The current Dart SDK version -dev.0.0.flutter-be ...
- 基于七牛Python SDK写的一个批量下载脚本
前言 上一篇基于七牛Python SDK写的一个同步脚本所写的脚本只支持上传,不支持文件下载. 虽然这个需求不太强烈,但有可能有人(在备份.迁移时)需要,而官方有没提供对应的工具,所以我就把这个功能也 ...
- Flutter SDK的下载与安装步骤 (mac版)
本月初(应该是2018年12月4日),Google在其Flutter Live 2018大会上正式发布 Flutter 1.0 版本. 当然我们不会怀疑Google团队的技术实力,但它和React N ...
- Android学习-- 基于位置的服务 LBS(基于百度地图Android SDK)--定位SDK
原文:Android学习-- 基于位置的服务 LBS(基于百度地图Android SDK)--定位SDK 版权声明:本文为博主原创文章,未经博主允许不得转载. https://blog.csdn.ne ...
随机推荐
- How to find WWN and WWPN of HBA card in Linux
There are several ways to detect the WWN of a Fibre Channel (FC) HBA and their details in Linux/Unix ...
- AJAX-动力节点
AJAX(Asynchronous Javascript And Xml) 传统请求及缺点 传统的请求都有哪些? 直接在浏览器地址栏上输入URL. 点击超链接 提交form表单 使用JS代码发送请求 ...
- POJ--2689-C++
题意很简单就是让你求给定区间的素数,然后用一个循环求出相距最远的相邻素数数和最近的素数以及相距最近的相邻素数 难点在与数据很大,所以不可能直接对区间的每一个数进行素数判断.但是,每个合数n都至少有一个 ...
- Win32游戏开发程序——第一个“游戏”程序
在中国大学慕课上面看到的一道题目,算得上是入门吧. 题目如下: 在"HelloWorldGame"游戏代码的基础上,试着进行如下的上机练习(能实现至少一个即可): • 将文字修改 ...
- bzoj 3532
很好的一道题,对理解最小割有很大帮助 首先,不难发现本题与网络流24题中的某一道很类似,我们可以先跑一次dp求出每个节点的LIS,然后拆点,拆出的两点之间连流量为删除的代价的边,剩下的点之间按dp的转 ...
- js 获取年月日时分秒,星期
getDate() { var date = new Date() // 获取时间 var year = date.getFullYear() // 获取年 var month = date.getM ...
- win电脑查看wifi密码的方法
1.使用电脑连接需要查看的WiFi,鼠标右击电脑桌面右下角[WiFi图标],在弹出的菜单中点击[打开"网络和internet"设置]. 2.在弹出的设置窗口中点击[网络和共享中心] ...
- linux清除日志和文件缓存
1.查找大文件,从根目录 find / -type f -size +800M 2.查看磁盘挂载情况及挂载目录 df -lh 3.查看内存使用情况 free -m 4.清除缓存 echo 3 > ...
- 【Hive】数据倾斜原因及解决方法汇总
1)数据倾斜根本原因:由于数据分布不均匀,导致map端读取的数据分布不均匀(数据长尾分布),从而使得map处理的数据量差异过大. (2)解决思路:Hive是分阶段执行的,map处理数据量的差异取决于上 ...
- mysql 参数配置
https://www.jb51.net/article/48082.htm https://www.cnblogs.com/angryprogrammer/p/6667741.html