WebSocket允许Mobile App与服务器之间的实时通信。您将通过一个基本的实时多人游戏实例来学习这些原理。
难度:中级
前言
除非您正在构建不需要与服务器交换信息的应用程序,否则必须在移动应用程序和服务器之间进行通信。
HTTP客户端服务器
通常,移动应用程序与服务器之间的通信是通过HTTP协议实现的,藏宝库 28xin.com其中客户端(=移动应用程序)通过Internet向服务器发送HTTP请求。服务器处理完请求后,它将答案返回给客户端并关闭连接。
这是一种单向通信,其中通信必须始终由客户端发起,并且包含一次交换(发送->接收->关闭)。如果客户端没有要求,服务器就不可能将任何东西发送给客户端。
在大多数情况下,这种通信方式就足够了,甚至推荐使用。但是,如果您需要非常频繁地轮询服务器,移动大量数据,根据服务器端可能发生的事件做出反应,则这种通信方式可能会成为瓶颈。
Web套接字
其他一些类型的应用程序,例如聊天,实时游戏,拍卖 ……可能需要:
- 具有比单个请求/响应方案更长的一段时间,客户端和服务器之间保持开放状态的通信通道
- 具有双向数据传输,其中服务器可以在没有客户端请求/轮询的情况下将数据发送到客户端
- 支持数据流
WebSockets允许客户端打开并保持与服务器的连接。
Web套接字是客户端和服务器之间通过网络进行的TCP套接字连接,它允许全双工通信,换句话说:数据可以在两个方向上同时传输。一个TCP套接字是一个端点实例,通过IP地址和端口来定义。
有关WebSockets技术方面的详细文档,请参考RFC6455。
Flutter中的WebSockets
所述web_socket_channel包提供包装为WebSocket连接。
要安装此软件包,请将以下行添加到您的pubspec.yaml文件中:
dependencies:
web_socket_channel: "^1.0.8"
要导入软件包,请将以下2种导入添加到您的.dart文件中:
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/status.dart' as status;
如何连接到服务器?
要连接到服务器,您至少需要知道:
- 它的TCP套接字地址(例如192.168.1.25:1234)
- Web套接字处理程序的名称(例如“ / svc / websockets”)
- URL方案(对于纯文本通信,为“ ws://”;对于加密通道,为“ wss://”)
IOWebSocketChannel channel = new IOWebSocketChannel.connect("ws://192.168.1.25:1234/svc/websockets");
引擎盖下
这条简单的线向服务器发送常规的HTTP请求,该请求还包含“ Upgrade ”标头,以通知服务器客户端希望建立WebSocket连接。该请求启动“ 握手 ”过程。如果服务器支持WebSocket协议,则它同意升级并通过响应中的“ Upgrade ”标头进行通信。握手完成后,初始HTTP连接将被使用相同基础TCP / IP连接的WebSocket连接取代。
该通道现已开放,随时可以使用。
安全吗?
好吧,如果仅依赖基本协议“ ws://”,则它与普通的“ http://”协议一样安全。
因此,强烈建议使用与HTTPS(TLS / SSL)相同的加密。
要建立这种安全的通信,您需要:
- 服务器上安装的SSL证书
- 使用“ wss:// ”而不是“ ws:// ”
- 指向SSL端口
通过对客户端进行身份验证来扩展安全性
如前一篇文章所述,您还可以使用“ 令牌 ” 的概念,并且仅允许经过身份验证的客户端进行连接。
因此,您还可以在连接期间将一些额外的数据传递到标头。
以下示例说明了如何在请求标头中传递额外的数据。
///
/// Open a new secured WebSocket communication
///
IOWebSocketChannel channel;
try {
channel = new IOWebSocketChannel.connect(
"wss://192.168.1.25:443/svc/websockets",
headers: {
'X-DEVICE-ID': deviceIdentity,
'X-TOKEN': token,
'X-APP-ID': applicationId,
});
...
} catch(e){
///
/// An error occurred
///
}
如何关闭通讯?
客户端可以使用以下命令关闭通信通道:
channel.sink.close();
如何向服务器发送消息?
channel.sink.add('the data I need to send to the server');
如何处理来自服务器的通信?
为了能够接受服务器发出的传入消息,您需要从Stream订阅(侦听)事件。
签名如下:
StreamSubscription<T> channel.stream.listen(
void onData(T event),
{
Function onError,
void onDone(),
bool cancelOnError
}
);
哪里:
- onData:当从服务器接收到某些数据时调用的方法
- onError:处理任何错误的方法
- onDone:用于处理通信关闭的方法(例如,从Server)
- cancelOnError:(默认为false)。如果设置为true,则在第一个错误事件发生时自动关闭StreamSubscription
因此,典型的实现将是:
channel.stream.listen(
(message){
// handling of the incoming messages
},
onError: function(error, StackTrace stackTrace){
// error handling
},
onDone: function(){
// communication has been closed
}
);
让我们实践一下
与您可以在Internet上找到的大多数示例不同藏宝库 28xin.com,我没有重写通常的Chat应用程序来解释该主题。相反,我们将构建实时多人游戏的框架。像井字游戏一样。
该示例包括:
- 用NodeJS编写的Websockets服务器
- 一个移动应用程序游戏,其中:
- 用户将提供他们的名字以加入游戏;
- 所有玩家的名单将实时刷新;
- 一个用户将选择另一位玩家来开始新游戏;
- 会模拟地通知两名玩家进入Tic-Tac-Toe棋盘游戏;
- 玩家将可以:
免责声明
本示例仅用于说明主题。我们要编写的游戏框架非常基本,还不完整,并且需要进行大量改进,验证……
高级视图
首先,我们需要从客户端和服务器端来描述游戏。
客户端
客户端(移动应用程序本身)将包含2个屏幕。
- 屏幕1:
该屏幕将允许用户:
- 输入玩家名称并加入游戏(=动作:“ join ”)
- 查看所有加入游戏的玩家的列表(=动作:“ players_list ”)
- 选择一个玩家并开始一个新游戏(=动作:“ new_game ”)然后,这两个玩家将自动进入第二个屏幕。
- 屏幕2:
该屏幕将显示:
- 对手的名字
- 辞职按钮。如果用户点击此按钮,玩家将退出游戏(=动作:“ resign ”),然后将两个玩家带回到屏幕1
- 一个井字游戏网格,由9个单元组成。
- 当玩家单击网格的某个单元格时,该玩家的符号(“ X”或“ O”)将显示在两个玩家的移动应用中
服务器端
服务器端只会:
- 记录所有球员的名单,并为他们提供唯一的ID
- 当新玩家加入时,将该列表广播给所有玩家
- 记录新游戏的玩家藏宝库 28xin.com
- 将一个玩家的动作传达给另一位玩家
通讯协议
为了在播放器和服务器之间进行通信,我们需要定义某种语言。我们称此为“ 协议 ”。
将发送到服务器或从服务器发送到Mobile App的所有消息都将遵循此协议,其中包括:
为了方便起见,我将使用以下JSON对象(= Map):
{
"action": "action name",
"data": "data to be sent"
}
下图显示了该协议:
服务器端:WebSocket服务器
对于这个非常基本的WebSocket服务器,我选择使用“ WebSocket ”包在NodeJS中实现。
先决条件
为了进行这项工作,您需要:
- 安装NodeJS(版本> 6)(有关安装NodeJS的更多详细信息,请参考NodeJS网站)。
- 安装“ websocket ”软件包,使用“ npm install websocket –save ”安装软件包
源代码
源代码是此WebSocket服务器非常基础。首先让我们看一下,解释与代码一起进行。
/**
* Parameters
*/
var webSocketsServerPort = 34263; // Adapt to the listening port number you want to use
/**
* Global variables
*/
// websocket and http servers
var webSocketServer = require('websocket').server;
var http = require('http');
/**
* HTTP server to implement WebSockets
*/
var server = http.createServer(function(request, response) {
// Not important for us. We're writing WebSocket server,
// not HTTP server
});
server.listen(webSocketsServerPort, function() {
console.log((new Date()) + " Server is listening on port "
+ webSocketsServerPort);
});
/**
* WebSocket server
*/
var wsServer = new webSocketServer({
// WebSocket server is tied to a HTTP server. WebSocket
// request is just an enhanced HTTP request. For more info
httpServer: server
});
// This callback function is called every time someone
// tries to connect to the WebSocket server
wsServer.on('request', function(request) {
var connection = request.accept(null, request.origin);
//
// New Player has connected. So let's record its socket
//
var player = new Player(request.key, connection);
//
// Add the player to the list of all players
//
Players.push(player);
//
// We need to return the unique id of that player to the player itself
//
connection.sendUTF(JSON.stringify({action: 'connect', data: player.id}));
//
// Listen to any message sent by that player
//
connection.on('message', function(data) {
//
// Process the requested action
//
var message = JSON.parse(data.utf8Data);
switch(message.action){
//
// When the user sends the "join" action, he provides a name.
// Let's record it and as the player has a name, let's
// broadcast the list of all the players to everyone
//
case 'join':
player.name = message.data;
BroadcastPlayersList();
break;
//
// When a player resigns, we need to break the relationship
// between the 2 players and notify the other player
// that the first one resigned
//
case 'resign':
console.log('resigned');
Players[player.opponentIndex]
.connection
.sendUTF(JSON.stringify({'action':'resigned'}));
setTimeout(function(){
Players[player.opponentIndex].opponentIndex = player.opponentIndex = null;
}, 0);
break;
//
// A player initiates a new game.
// Let's create a relationship between the 2 players and
// notify the other player that a new game starts
//
case 'new_game':
player.setOpponent(message.data);
Players[player.opponentIndex]
.connection
.sendUTF(JSON.stringify({'action':'new_game', 'data': player.name}));
break;
//
// A player sends a move. Let's forward the move to the other player
//
case 'play':
Players[player.opponentIndex]
.connection
.sendUTF(JSON.stringify({'action':'play', 'data': message.data}));
break;
}
});
// user disconnected
connection.on('close', function(connection) {
// We need to remove the corresponding player
// TODO
});
});
// -----------------------------------------------------------
// List of all players
// -----------------------------------------------------------
var Players = [];
function Player(id, connection){
this.id = id;
this.connection = connection;
this.name = "";
this.opponentIndex = null;
this.index = Players.length;
}
Player.prototype = {
getId: function(){
return {name: this.name, id: this.id};
},
setOpponent: function(id){
var self = this;
Players.forEach(function(player, index){
if (player.id == id){
self.opponentIndex = index;
Players[index].opponentIndex = self.index;
return false;
}
});
}
};
// ---------------------------------------------------------
// Routine to broadcast the list of all players to everyone
// ---------------------------------------------------------
function BroadcastPlayersList(){
var playersList = [];
Players.forEach(function(player){
if (player.name !== ''){
playersList.push(player.getId());
}
});
var message = JSON.stringify({
'action': 'players_list',
'data': playersList
});
Players.forEach(function(player){
player.connection.sendUTF(message);
});
}
客户端:移动应用
现在我们有了服务器,让我们考虑Flutter应用程序。
Websocket助手
让我们从实现WebSocket Helper类开始。
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/io.dart';
///
/// Application-level global variable to access the WebSockets
///
WebSocketsNotifications sockets = new WebSocketsNotifications();
///
/// Put your WebSockets server IP address and port number
///
const String _SERVER_ADDRESS = "ws://192.168.1.45:34263";
class WebSocketsNotifications {
static final WebSocketsNotifications _sockets = new WebSocketsNotifications._internal();
factory WebSocketsNotifications(){
return _sockets;
}
WebSocketsNotifications._internal();
///
/// The WebSocket "open" channel
///
IOWebSocketChannel _channel;
///
/// Is the connection established?
///
bool _isOn = false;
///
/// Listeners
/// List of methods to be called when a new message
/// comes in.
///
ObserverList<Function> _listeners = new ObserverList<Function>();
/// ----------------------------------------------------------
/// Initialization the WebSockets connection with the server
/// ----------------------------------------------------------
initCommunication() async {
///
/// Just in case, close any previous communication
///
reset();
///
/// Open a new WebSocket communication
///
try {
_channel = new IOWebSocketChannel.connect(_SERVER_ADDRESS);
///
/// Start listening to new notifications / messages
///
_channel.stream.listen(_onReceptionOfMessageFromServer);
} catch(e){
///
/// General error handling
/// TODO
///
}
}
/// ----------------------------------------------------------
/// Closes the WebSocket communication
/// ----------------------------------------------------------
reset(){
if (_channel != null){
if (_channel.sink != null){
_channel.sink.close();
_isOn = false;
}
}
}
/// ---------------------------------------------------------
/// Sends a message to the server
/// ---------------------------------------------------------
send(String message){
if (_channel != null){
if (_channel.sink != null && _isOn){
_channel.sink.add(message);
}
}
}
/// ---------------------------------------------------------
/// Adds a callback to be invoked in case of incoming
/// notification
/// ---------------------------------------------------------
addListener(Function callback){
_listeners.add(callback);
}
removeListener(Function callback){
_listeners.remove(callback);
}
/// ----------------------------------------------------------
/// Callback which is invoked each time that we are receiving
/// a message from the server
/// ----------------------------------------------------------
_onReceptionOfMessageFromServer(message){
_isOn = true;
_listeners.forEach((Function callback){
callback(message);
});
}
}
此类是处理基于WebSocket的通信的Singleton。
我将其实现为Singleton,以允许其在整个应用程序中重用,而不必关心连接。导入此.dart文件的简单事实足以使用套接字(应用程序级全局变量)。
藏宝库 28xin.com游戏通讯助手类
此类负责处理与游戏有关的网络套接字通信。由于将由2个屏幕使用,因此该类也实现为Singleton。
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'websockets.dart';
///
/// Again, application-level global variable
///
GameCommunication game = new GameCommunication();
class GameCommunication {
static final GameCommunication _game = new GameCommunication._internal();
///
/// At first initialization, the player has not yet provided any name
///
String _playerName = "";
///
/// Before the "join" action, the player has no unique ID
///
String _playerID = "";
factory GameCommunication(){
return _game;
}
GameCommunication._internal(){
///
/// Let's initialize the WebSockets communication
///
sockets.initCommunication();
///
/// and ask to be notified as soon as a message comes in
///
sockets.addListener(_onMessageReceived);
}
///
/// Getter to return the player's name
///
String get playerName => _playerName;
/// ----------------------------------------------------------
/// Common handler for all received messages, from the server
/// ----------------------------------------------------------
_onMessageReceived(serverMessage){
///
/// As messages are sent as a String
/// let's deserialize it to get the corresponding
/// JSON object
///
Map message = json.decode(serverMessage);
switch(message["action"]){
///
/// When the communication is established, the server
/// returns the unique identifier of the player.
/// Let's record it
///
case 'connect':
_playerID = message["data"];
break;
///
/// For any other incoming message, we need to
/// dispatch it to all the listeners
///
default:
_listeners.forEach((Function callback){
callback(message);
});
break;
}
}
/// ----------------------------------------------------------
/// Common method to send requests to the server
/// ----------------------------------------------------------
send(String action, String data){
///
/// When a player joins, we need to record the name
/// he provides
///
if (action == 'join'){
_playerName = data;
}
///
/// Send the action to the server
/// To send the message, we need to serialize the JSON
///
sockets.send(json.encode({
"action": action,
"data": data
}));
}
/// ==========================================================
///
/// Listeners to allow the different pages to be notified
/// when messages come in
///
ObserverList<Function> _listeners = new ObserverList<Function>();
/// ---------------------------------------------------------
/// Adds a callback to be invoked in case of incoming
/// notification
/// ---------------------------------------------------------
addListener(Function callback){
_listeners.add(callback);
}
removeListener(Function callback){
_listeners.remove(callback);
}
}
为什么选择在WebSockets Helper之上实现游戏通讯助手?
仅仅是因为,与游戏相关的所有逻辑都是集中的。另外,因为例如,如果我们想通过添加一些聊天功能来扩展游戏,我们只需创建一个特定的类,该类也将依赖于相同的WebSockets Helper。
屏幕1:用户加入并启动新游戏的位置
该屏幕负责:
- 让用户加入游戏,提供一个名称
- 维护所有球员的实时名单
- 让用户与其他玩家开始新游戏
import 'package:flutter/material.dart';
import 'game_communication.dart';
import 'game_page.dart';
class StartPage extends StatefulWidget {
@override
_StartPageState createState() => _StartPageState();
}
class _StartPageState extends State<StartPage> {
static final TextEditingController _name = new TextEditingController();
String playerName;
List<dynamic> playersList = <dynamic>[];
@override
void initState() {
super.initState();
///
/// Ask to be notified when messages related to the game
/// are sent by the server
///
game.addListener(_onGameDataReceived);
}
@override
void dispose() {
game.removeListener(_onGameDataReceived);
super.dispose();
}
/// -------------------------------------------------------------------
/// This routine handles all messages that are sent by the server.
/// In this page, only the following 2 actions have to be processed
/// - players_list
/// - new_game
/// -------------------------------------------------------------------
_onGameDataReceived(message) {
switch (message["action"]) {
///
/// Each time a new player joins, we need to
/// * record the new list of players
/// * rebuild the list of all the players
///
case "players_list":
playersList = message["data"];
// force rebuild
setState(() {});
break;
///
/// When a game is launched by another player,
/// we accept the new game and automatically redirect
/// to the game board.
/// As we are not the new game initiator, we will be
/// using the "O"
///
case 'new_game':
Navigator.push(context, new MaterialPageRoute(
builder: (BuildContext context)
=> new GamePage(
opponentName: message["data"], // Name of the opponent
character: 'O',
),
));
break;
}
}
/// -----------------------------------------------------------
/// If the user has not yet joined, let the user enter
/// his/her name and join the list of players
/// -----------------------------------------------------------
Widget _buildJoin() {
if (game.playerName != "") {
return new Container();
}
return new Container(
padding: const EdgeInsets.all(16.0),
child: new Column(
children: <Widget>[
new TextField(
controller: _name,
keyboardType: TextInputType.text,
decoration: new InputDecoration(
hintText: 'Enter your name',
contentPadding: const EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0),
border: new OutlineInputBorder(
borderRadius: new BorderRadius.circular(32.0),
),
icon: const Icon(Icons.person),
),
),
new Padding(
padding: const EdgeInsets.all(8.0),
child: new RaisedButton(
onPressed: _onGameJoin,
child: new Text('Join...'),
),
),
],
),
);
}
/// ------------------------------------------------------
/// The user wants to join, so let's send his/her name
/// As the user has a name, we may now show the other players
/// ------------------------------------------------------
_onGameJoin() {
game.send('join', _name.text);
/// Force a rebuild
setState(() {});
}
/// ------------------------------------------------------
/// Builds the list of players
/// ------------------------------------------------------
Widget _playersList() {
///
/// If the user has not yet joined, do not display
/// the list of players
///
if (game.playerName == "") {
return new Container();
}
///
/// Display the list of players.
/// For each of them, put a Button that could be used
/// to launch a new game
///
List<Widget> children = playersList.map((playerInfo) {
return new ListTile(
title: new Text(playerInfo["name"]),
trailing: new RaisedButton(
onPressed: (){
_onPlayGame(playerInfo["name"], playerInfo["id"]);
},
child: new Text('Play'),
),
);
}).toList();
return new Column(
children: children,
);
}
/// --------------------------------------------------------------
/// We launch a new Game, we need to:
/// * send the action "new_game", together with the ID
/// of the opponent we choosed
/// * redirect to the game board
/// As we are the game initiator, we will play with the "X"
/// --------------------------------------------------------------
_onPlayGame(String opponentName, String opponentId){
// We need to send the opponentId to initiate a new game
game.send('new_game', opponentId);
Navigator.push(context, new MaterialPageRoute(
builder: (BuildContext context)
=> new GamePage(
opponentName: opponentName,
character: 'X',
),
));
}
@override
Widget build(BuildContext context) {
return new SafeArea(
bottom: false,
top: false,
child: Scaffold(
appBar: new AppBar(
title: new Text('TicTacToe'),
),
body: SingleChildScrollView(
child: new Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
_buildJoin(),
new Text('List of players:'),
_playersList(),
],
),
),
),
);
}
}
屏幕2:游戏板
第二个屏幕负责:
- 在AppBar中显示对手的名字
- 允许用户辞职,经由“ 辞职 ”按钮
- 实时显示游戏以及所有动作
- 允许用户进行移动并将移动发送给对手。
import 'package:flutter/material.dart';
import 'game_communication.dart';
class GamePage extends StatefulWidget {
GamePage({
Key key,
this.opponentName,
this.character,
}): super(key: key);
///
/// Name of the opponent
///
final String opponentName;
///
/// Character to be used by the player for his/her moves ("X" or "O")
///
final String character;
@override
_GamePageState createState() => _GamePageState();
}
class _GamePageState extends State<GamePage> {
///
/// One game in terms of grid cells.
/// When the user plays, one of this cells is filled with "X" or "O"
///
List<String> grid = <String>["","","","","","","","",""];
@override
void initState(){
super.initState();
///
/// Ask to be notified when a message from the server
/// comes in.
///
game.addListener(_onAction);
}
@override
void dispose(){
game.removeListener(_onAction);
super.dispose();
}
/// ---------------------------------------------------------
/// The opponent took an action
/// Handler of these actions
/// ---------------------------------------------------------
_onAction(message){
switch(message["action"]){
///
/// The opponent resigned, so let's leave this screen
///
case 'resigned':
Navigator.of(context).pop();
break;
///
/// The opponent played a move.
/// So record it and rebuild the board
///
case 'play':
var data = (message["data"] as String).split(';');
grid[int.parse(data[0])] = data[1];
// Force rebuild
setState((){});
break;
}
}
/// ---------------------------------------------------------
/// This player resigns
/// We need to send this notification to the other player
/// Then, leave this screen
/// ---------------------------------------------------------
_doResign(){
game.send('resign', '');
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return new SafeArea(
top: false,
bottom: false,
child: new Scaffold(
appBar: new AppBar(
title: new Text('Game against: ${widget.opponentName}', style: new TextStyle(fontSize: 16.0)),
actions: <Widget>[
new RaisedButton(
onPressed: _doResign,
child: new Text('Resign'),
),
]
),
body: _buildBoard(),
),
);
}
/// --------------------------------------------------------
/// Builds the Game Board.
/// --------------------------------------------------------
Widget _buildBoard(){
return new SafeArea(
top: false,
bottom: false,
child: new GridView.builder(
gridDelegate: new SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: 9,
itemBuilder: (BuildContext context, int index){
return _gridItem(index);
},
),
);
}
Widget _gridItem(int index){
Color color = grid[index] == "X" ? Colors.blue : Colors.red;
return new InkWell(
onTap: () {
///
/// The user taps a cell.
/// If the latter is empty, let's put this player's character
/// and notify the other player.
/// Repaint the board
///
if (grid[index] == ""){
grid[index] = widget.character;
///
/// To send a move, we provide the cell index
/// and the character of this player
///
game.send('play', '$index;${widget.character}');
/// Force the board repaint
setState((){});
}
},
child: new GridTile(
child: new Card(
child: new FittedBox(
fit: BoxFit.contain,
child: new Text(grid[index], style: new TextStyle(fontSize: 50.0, color: color,))
),
),
),
);
}
}
主要的
最后,我们有只启动第一个屏幕的主例程。
import 'package:flutter/material.dart';
import 'start_page.dart';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'WebSockets Demo',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
home: new StartPage(),
);
}
}
结果
以下视频显示了运行此示例应用程序的2个移动设备。如您所见,交互是实时的。
28xin.com
结论WebSockets易于实现,并且在移动应用需要处理实时和全双工通信时必不可少。
我希望本文通过这个实际示例来揭开WebSockets概念的神秘面纱,该示例再次旨在演示使用WebSockets的通信。
请继续关注新文章和愉快的编码。