昨天,我在參加線上瑜伽課程時,才意識到我的日常活動中使用了這麼多的視訊直播 App--從商務會議到瑜伽課程,還有即興演奏和電影之夜。對於大多數居家隔離的人來說,視訊直播是接近世界的最好方式。海量使用者的觀看和直播,也讓“完美的流媒體 App”成為了新的市場訴求。
在這篇文章中,我將引導你使用聲網Agora Flutter SDK 開發自己的直播 App。你可以按照自己的需求來定製你的應用介面,同時還能夠保持最高的視訊質量和幾乎感受不到的延遲。
開發環境
如果你是 Flutter 的新手,那麼請訪問 Flutter 官網安裝 Flutter。
- 在https://pub.dev/搜尋“Agora”,下載聲網Agora Flutter SDK v3.2.1
- 在https://pub.dev/搜尋“Agora”,聲網Agora Flutter RTM SDK v0.9.14
- VS Code 或其他 IDE
- 聲網Agora 開發者賬戶,請訪問 Agora.io 註冊
專案設定
我們先建立一個 Flutter 專案。開啟你的終端,導航到你開發用的資料夾,然後輸入以下內容。
flutter create agora_live_streaming
導航到你的 pubspec.yaml 檔案,在該檔案中,新增以下依賴項:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.0
permission_handler: ^5.1.0+2
agora_rtc_engine: ^3.2.1
agora_rtm: ^0.9.14
在新增檔案壓縮包的時候,要注意縮排,以免出錯。
你的專案資料夾中,執行以下命令來安裝所有的依賴項:
flutter pub get
一旦我們有了所有的依賴項,我們就可以建立檔案結構了。導航到 lib 資料夾,並建立一個像這樣的檔案結構。
建立主頁面
首先,我建立了一個簡單的登入表單,需要輸入三個資訊:使用者名稱、頻道名稱和使用者角色(觀眾或主播)。你可以根據自己的需要來定製這個介面。
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final _username = TextEditingController();
final _channelName = TextEditingController();
bool _isBroadcaster = false;
String check = '';
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: true,
body: Center(
child: SingleChildScrollView(
physics: NeverScrollableScrollPhysics(),
child: Stack(
children: <Widget>[
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(30.0),
child: Image.network(
'https://www.agora.io/en/wp-content/uploads/2019/06/agoralightblue-1.png',
scale: 1.5,
),
),
Container(
width: MediaQuery.of(context).size.width * 0.85,
height: MediaQuery.of(context).size.height * 0.2,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
TextFormField(
controller: _username,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(color: Colors.grey),
),
hintText: 'Username',
),
),
TextFormField(
controller: _channelName,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(color: Colors.grey),
),
hintText: 'Channel Name',
),
),
],
),
),
Container(
width: MediaQuery.of(context).size.width * 0.65,
padding: EdgeInsets.symmetric(vertical: 10),
child: SwitchListTile(
title: _isBroadcaster
? Text('Broadcaster')
: Text('Audience'),
value: _isBroadcaster,
activeColor: Color.fromRGBO(45, 156, 215, 1),
secondary: _isBroadcaster
? Icon(
Icons.account_circle,
color: Color.fromRGBO(45, 156, 215, 1),
)
: Icon(Icons.account_circle),
onChanged: (value) {
setState(() {
_isBroadcaster = value;
print(_isBroadcaster);
});
}),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 25),
child: Container(
width: MediaQuery.of(context).size.width * 0.85,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(20)),
child: MaterialButton(
onPressed: onJoin,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Join ',
style: TextStyle(
color: Colors.white,
letterSpacing: 1,
fontWeight: FontWeight.bold,
fontSize: 20),
),
Icon(
Icons.arrow_forward,
color: Colors.white,
)
],
),
),
),
),
Text(
check,
style: TextStyle(color: Colors.red),
)
],
),
),
],
),
),
));
}
}
這樣就會建立一個類似於這樣的使用者介面:
每當按下“加入(Join)”按鈕,它就會呼叫onJoin 函式,該函式首先獲得使用者在通話過程中訪問其攝像頭和麥克風的許可權。一旦使用者授予這些許可權,我們就進入下一個頁面, broadcast_page.dart 。
Future<void> onJoin() async {
if (_username.text.isEmpty || _channelName.text.isEmpty) {
setState(() {
check = 'Username and Channel Name are required fields';
});
} else {
setState(() {
check = '';
});
await _handleCameraAndMic(Permission.camera);
await _handleCameraAndMic(Permission.microphone);
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BroadcastPage(
userName: _username.text,
channelName: _channelName.text,
isBroadcaster: _isBroadcaster,
),
),
);
}
}
為了要求使用者訪問攝像頭和麥克風,我們使用一個名為 permission_handler 的包。這裡我宣告瞭一個名為_handleCameraAndMic(),的函式,我將在onJoin()函式中引用它 。
Future<void> onJoin() async {
if (_username.text.isEmpty || _channelName.text.isEmpty) {
setState(() {
check = 'Username and Channel Name are required fields';
});
} else {
setState(() {
check = '';
});
await _handleCameraAndMic(Permission.camera);
await _handleCameraAndMic(Permission.microphone);
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BroadcastPage(
userName: _username.text,
channelName: _channelName.text,
isBroadcaster: _isBroadcaster,
),
),
);
}
}
建立我們的流媒體頁面
預設情況下,觀眾端的攝像頭是禁用的,麥克風也是靜音的,但主播端要提供兩者的訪問許可權。所以我們在建立介面的時候,會根據客戶端的角色來設計相應的樣式。
每當使用者選擇觀眾角色時,就會呼叫這個頁面,在這裡他們可以觀看主播的直播,並可以選擇與主播聊天互動。
但當使用者選擇作為主播角色加入時,可以看到該頻道中其他主播的流,並可以選擇與頻道中的所有人(主播和觀眾)進行互動。
下面我們開始建立介面。
class BroadcastPage extends StatefulWidget {
final String channelName;
final String userName;
final bool isBroadcaster;
const BroadcastPage({Key key, this.channelName, this.userName, this.isBroadcaster}) : super(key: key);
@override
_BroadcastPageState createState() => _BroadcastPageState();
}
class _BroadcastPageState extends State<BroadcastPage> {
final _users = <int>[];
final _infoStrings = <String>[];
RtcEngine _engine;
bool muted = false;
@override
void dispose() {
// clear users
_users.clear();
// destroy sdk and leave channel
_engine.destroy();
super.dispose();
}
@override
void initState() {
super.initState();
// initialize agora sdk
initialize();
}
Future<void> initialize() async {
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Stack(
children: <Widget>[
_viewRows(),
_toolbar(),
],
),
),
);
}
}
在這裡,我建立了一個名為 BroadcastPage 的 StatefulWidget,它的建構函式包括了頻道名稱、使用者名稱和 isBroadcaster(布林值)的值。
在我們的 BroadcastPage 類中,我們宣告一個 RtcEngine 類的物件。為了初始化這個物件,我們建立一個initState()方法,在這個方法中我們呼叫了初始化函式。
initialize() 函式不僅初始化聲網Agora SDK,它也是呼叫的其他主要函式的函式,如_initAgoraRtcEngine(),_addAgoraEventHandlers(), 和joinChannel()。
Future<void> initialize() async {
print('Client Role: ${widget.isBroadcaster}');
if (appId.isEmpty) {
setState(() {
_infoStrings.add(
'APP_ID missing, please provide your APP_ID in settings.dart',
);
_infoStrings.add('Agora Engine is not starting');
});
return;
}
await _initAgoraRtcEngine();
_addAgoraEventHandlers();
await _engine.joinChannel(null, widget.channelName, null, 0);
}
現在讓我們來了解一下我們在initialize()中呼叫的這三個函式的意義。
- _initAgoraRtcEngine()用於建立聲網Agora SDK的例項。使用你從聲網Agora開發者後臺得到的專案App ID來初始化它。在這裡面,我們使用enableVideo()函式來啟用視訊模組。為了將頻道配置檔案從視訊通話(預設值)改為直播,我們呼叫setChannelProfile() 方法,然後設定使用者角色。
Future<void> _initAgoraRtcEngine() async {
_engine = await RtcEngine.create(appId);
await _engine.enableVideo();
await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
if (widget.isBroadcaster) {
await _engine.setClientRole(ClientRole.Broadcaster);
} else {
await _engine.setClientRole(ClientRole.Audience);
}
}
- _addAgoraEventHandlers()是一個處理所有主要回撥函式的函式。我們從setEventHandler()開始,它監聽engine事件並接收相應RtcEngine的統計資料。
一些重要的回撥包括:
- joinChannelSuccess()在本地使用者加入指定頻道時被觸發。它返回頻道名,使用者的uid,以及本地使用者加入頻道所需的時間(以毫秒為單位)。
- leaveChannel()與joinChannelSuccess()相反,因為它是在使用者離開頻道時觸發的。每當使用者離開頻道時,它就會返回撥用的統計資訊。這些統計包括延遲、CPU使用率、持續時間等。
- userJoined()是一個當遠端使用者加入一個特定頻道時被觸發的方法。一個成功的回撥會返回遠端使用者的id和經過的時間。
- userOffline()與userJoined() 相反,因為它發生在使用者離開頻道的時候。一個成功的回撥會返回uid和離線的原因,包括掉線、退出等。
- firstRemoteVideoFrame()是一個當遠端視訊的第一個視訊幀被渲染時被呼叫的方法,它可以幫助你返回uid、寬度、高度和經過的時間。
void _addAgoraEventHandlers() {
_engine.setEventHandler(RtcEngineEventHandler(error: (code) {
setState(() {
final info = 'onError: $code';
_infoStrings.add(info);
});
}, joinChannelSuccess: (channel, uid, elapsed) {
setState(() {
final info = 'onJoinChannel: $channel, uid: $uid';
_infoStrings.add(info);
});
}, leaveChannel: (stats) {
setState(() {
_infoStrings.add('onLeaveChannel');
_users.clear();
});
}, userJoined: (uid, elapsed) {
setState(() {
final info = 'userJoined: $uid';
_infoStrings.add(info);
_users.add(uid);
});
}, userOffline: (uid, elapsed) {
setState(() {
final info = 'userOffline: $uid';
_infoStrings.add(info);
_users.remove(uid);
});
},
));
}
- joinChannel()一個頻道在視訊通話中就是一個房間。一個joinChannel()函式可以幫助使用者訂閱一個特定的頻道。這可以使用我們的RtcEngine物件來宣告:
await _engine.joinChannel(token, "channel-name", "Optional Info", uid);
注意:此專案是開發環境,僅供參考,請勿直接用於生產環境。建議在生產環境中執行的所有RTE App都使用Token鑑權。關於聲網Agora平臺中基於Token鑑權的更多資訊,請參考聲網文件中心:https://docs.agora.io/cn。
以上總結了製作這個實時互動視訊直播所需的所有功能和方法。現在我們可以製作我們的元件了,它將負責我們應用的完整使用者介面。
在我的方法中,我宣告瞭兩個小部件(_viewRows()和_toolbar(),它們負責顯示主播的網格,以及一個由斷開、靜音、切換攝像頭和訊息按鈕組成的工具欄。
我們從 _viewRows()開始。為此,我們需要知道主播和他們的uid來顯示他們的視訊。我們需要一個帶有他們uid的本地和遠端使用者的通用列表。為了實現這一點,我們建立一個名為_getRendererViews()的小元件,其中我們使用了RtcLocalView和RtcRemoteView.。
List<Widget> _getRenderViews() {
final List<StatefulWidget> list = [];
if(widget.isBroadcaster) {
list.add(RtcLocalView.SurfaceView());
}
_users.forEach((int uid) => list.add(RtcRemoteView.SurfaceView(uid: uid)));
return list;
}
/// Video view wrapper
Widget _videoView(view) {
return Expanded(child: Container(child: view));
}
/// Video view row wrapper
Widget _expandedVideoRow(List<Widget> views) {
final wrappedViews = views.map<Widget>(_videoView).toList();
return Expanded(
child: Row(
children: wrappedViews,
),
);
}
/// Video layout wrapper
Widget _viewRows() {
final views = _getRenderViews();
switch (views.length) {
case 1:
return Container(
child: Column(
children: <Widget>[_videoView(views[0])],
));
case 2:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow([views[0]]),
_expandedVideoRow([views[1]])
],
));
case 3:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 3))
],
));
case 4:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 4))
],
));
default:
}
return Container();
}
有了它,你就可以實現一個完整的視訊通話app。為了增加斷開通話、靜音、切換攝像頭和訊息等功能,我們將建立一個名為__toolbar() 有四個按鈕的基本小元件。然後根據使用者角色對這些按鈕進行樣式設計,這樣觀眾只能進行聊天,而主播則可以使用所有的功能:
Widget _toolbar() {
return widget.isBroadcaster
? Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.symmetric(vertical: 48),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
RawMaterialButton(
onPressed: _onToggleMute,
child: Icon(
muted ? Icons.mic_off : Icons.mic,
color: muted ? Colors.white : Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: muted ? Colors.blueAccent : Colors.white,
padding: const EdgeInsets.all(12.0),
),
RawMaterialButton(
onPressed: () => _onCallEnd(context),
child: Icon(
Icons.call_end,
color: Colors.white,
size: 35.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.redAccent,
padding: const EdgeInsets.all(15.0),
),
RawMaterialButton(
onPressed: _onSwitchCamera,
child: Icon(
Icons.switch_camera,
color: Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.white,
padding: const EdgeInsets.all(12.0),
),
RawMaterialButton(
onPressed: _goToChatPage,
child: Icon(
Icons.message_rounded,
color: Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.white,
padding: const EdgeInsets.all(12.0),
),
],
),
)
: Container(
alignment: Alignment.bottomCenter,
padding: EdgeInsets.only(bottom: 48),
child: RawMaterialButton(
onPressed: _goToChatPage,
child: Icon(
Icons.message_rounded,
color: Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.white,
padding: const EdgeInsets.all(12.0),
),
);
}
讓我們來看看我們宣告的四個功能:
- _onToggleMute()可以讓你的資料流靜音或者取消靜音。這裡,我們使用 muteLocalAudioStream()方法,它採用一個布林輸入來使資料流靜音或取消靜音。
void _onToggleMute() {
setState(() {
muted = !muted;
});
_engine.muteLocalAudioStream(muted);
}
- _onSwitchCamera()可以讓你在前攝像頭和後攝像頭之間切換。在這裡,我們使用switchCamera()方法,它可以幫助你實現所需的功能。
void _onSwitchCamera() {
_engine.switchCamera();
}
- _onCallEnd()斷開呼叫並返回主頁 。
void _onCallEnd(BuildContext context) {
Navigator.pop(context);
}
- _goToChatPage() 導航到聊天介面。
void _goToChatPage() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => RealTimeMessaging(
channelName: widget.channelName,
userName: widget.userName,
isBroadcaster: widget.isBroadcaster,
),)
);
}
建立我們的聊天螢幕
為了擴充套件觀眾和主播之間的互動,我們新增了一個聊天頁面,任何人都可以傳送訊息。要做到這一點,我們使用聲網Agora Flutter RTM 包,它提供了向特定同行傳送訊息或向頻道廣播訊息的選項。在本教程中,我們將把訊息廣播到頻道上。
我們首先建立一個有狀態的小元件,它的建構函式擁有所有的輸入值:頻道名稱、使用者名稱和isBroadcaster。我們將在我們的邏輯中使用這些值,也將在我們的頁面設計中使用這些值。
為了初始化我們的 SDK,我們宣告initState()方法,其中我宣告的是_createClient(),它負責初始化。
class RealTimeMessaging extends StatefulWidget {
final String channelName;
final String userName;
final bool isBroadcaster;
const RealTimeMessaging(
{Key key, this.channelName, this.userName, this.isBroadcaster})
: super(key: key);
@override
_RealTimeMessagingState createState() => _RealTimeMessagingState();
}
class _RealTimeMessagingState extends State<RealTimeMessaging> {
bool _isLogin = false;
bool _isInChannel = false;
final _channelMessageController = TextEditingController();
final _infoStrings = <String>[];
AgoraRtmClient _client;
AgoraRtmChannel _channel;
@override
void initState() {
super.initState();
_createClient();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildInfoList(),
Container(
width: double.infinity,
alignment: Alignment.bottomCenter,
child: _buildSendChannelMessage(),
),
],
),
)),
);
}
}
在我們的_createClient()函式中,我們建立一個 AgoraRtmClient 物件。這個物件將被用來登入和登出一個特定的頻道。
void _createClient() async {
_client = await AgoraRtmClient.createInstance(appId);
_client.onMessageReceived = (AgoraRtmMessage message, String peerId) {
_logPeer(message.text);
};
_client.onConnectionStateChanged = (int state, int reason) {
print('Connection state changed: ' +
state.toString() +
', reason: ' +
reason.toString());
if (state == 5) {
_client.logout();
print('Logout.');
setState(() {
_isLogin = false;
});
}
};
_toggleLogin();
_toggleJoinChannel();
}
在我的_createClient()函式中,我引用了另外兩個函式:
- _toggleLogin()使用 AgoraRtmClient 物件來登入和登出一個頻道。它需要一個Token和一個 user ID 作為引數。這裡,我使用使用者名稱作為使用者ID。
void _toggleLogin() async {
if (!_isLogin) {
try {
await _client.login(null, widget.userName);
print('Login success: ' + widget.userName);
setState(() {
_isLogin = true;
});
} catch (errorCode) {
print('Login error: ' + errorCode.toString());
}
}
}
- _toggleJoinChannel()建立了一個AgoraRtmChannel物件,並使用這個物件來訂閱一個特定的頻道。這個物件將被用於所有的回撥,當一個成員加入,一個成員離開,或者一個使用者收到訊息時,回撥都會被觸發。
void _toggleJoinChannel() async {
try {
_channel = await _createChannel(widget.channelName);
await _channel.join();
print('Join channel success.');
setState(() {
_isInChannel = true;
});
} catch (errorCode) {
print('Join channel error: ' + errorCode.toString());
}
}
到這裡,你將擁有一個功能齊全的聊天應用。現在我們可以製作小元件了,它將負責我們應用的完整使用者介面。
這裡,我宣告瞭兩個小元件:_buildSendChannelMessage()和_buildInfoList().
- _buildSendChannelMessage()建立一個輸入欄位並觸發一個函式來傳送訊息。
- _buildInfoList()對訊息進行樣式設計,並將它們放在唯一 的容器中。你可以根據設計需求來定製這些小元件。
這裡有兩個小元件:
- _buildSendChannelMessage()我已經宣告瞭一個Row,它新增了一個文字輸入欄位和一 個按鈕,這個按鈕在被按下時呼叫 _toggleSendChannelMessage。
Widget _buildSendChannelMessage() {
if (!_isLogin || !_isInChannel) {
return Container();
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Container(
width: MediaQuery.of(context).size.width * 0.75,
child: TextFormField(
showCursor: true,
enableSuggestions: true,
textCapitalization: TextCapitalization.sentences,
controller: _channelMessageController,
decoration: InputDecoration(
hintText: 'Comment...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(color: Colors.grey, width: 2),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(color: Colors.grey, width: 2),
),
),
),
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(40)),
border: Border.all(
color: Colors.blue,
width: 2,
)),
child: IconButton(
icon: Icon(Icons.send, color: Colors.blue),
onPressed: _toggleSendChannelMessage,
),
)
],
);
}
這個函式呼叫我們之前宣告的物件使用的 AgoraRtmChannel 類中的 sendMessage()方法。這用到一個型別為 AgoraRtmMessage 的輸入。
void _toggleSendChannelMessage() async {
String text = _channelMessageController.text;
if (text.isEmpty) {
print('Please input text to send.');
return;
}
try {
await _channel.sendMessage(AgoraRtmMessage.fromText(text));
_log(text);
_channelMessageController.clear();
} catch (errorCode) {
print('Send channel message error: ' + errorCode.toString());
}
}
_buildInfoList()將所有本地訊息排列在右邊,而使用者收到的所有訊息則在左邊。然後,這個文字訊息被包裹在一個容器內,並根據你的需要進行樣式設計。
Widget _buildInfoList() {
return Expanded(
child: Container(
child: _infoStrings.length > 0
? ListView.builder(
reverse: true,
itemBuilder: (context, i) {
return Container(
child: ListTile(
title: Align(
alignment: _infoStrings[i].startsWith('%')
? Alignment.bottomLeft
: Alignment.bottomRight,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 3),
color: Colors.grey,
child: Column(
crossAxisAlignment: _infoStrings[i].startsWith('%') ? CrossAxisAlignment.start : CrossAxisAlignment.end,
children: [
_infoStrings[i].startsWith('%')
? Text(
_infoStrings[i].substring(1),
maxLines: 10,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(color: Colors.black),
)
: Text(
_infoStrings[i],
maxLines: 10,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(color: Colors.black),
),
Text(
widget.userName,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 10,
),
)
],
),
),
),
),
);
},
itemCount: _infoStrings.length,
)
: Container()));
}
測試
一旦我們完成了實時直播應用的開發,我們可以在我們的裝置上進行測試。在終端中找到你的專案目錄,然後執行這個命令。
flutter run
結論
恭喜,你已經完成了自己的實時互動視訊直播應用,使用聲網Agora Flutter SDK開發了這個應用,並通過聲網Agora Flutter RTM SDK實現了互動。
獲取本文的 Demo:https://github.com/Meherdeep/Interactive-Broadcasting
獲取更多教程、Demo、技術幫助,請點選「閱讀原文」訪問聲網開發者社群。