如何用 Flutter開發一個直播應用
昨天,我在參加在線瑜伽課程時,才意識到我的日常活動中使用了這麼多的視頻直播 App--從商務會議到瑜伽課程,還有即興演奏和電影之夜。對於大多數居家隔離的人來説,視頻直播是接近世界的最好方式。海量用户的觀看和直播,也讓“完美的流媒體 App”成為了新的市場訴求。
在這篇文章中,我將引導你使用聲網Agora Flutter SDK 開發自己的直播 App。你可以按照自己的需求來定製你的應用界面,同時還能夠保持最高的視頻質量和幾乎感受不到的延遲。
開發環境
如果你是 Flutter 的新手,那麼請訪問 Flutter 官網安裝 Flutter。
- 在http://pub.dev/搜索“Agora”,下載聲網Agora Flutter SDK v3.2.1
- 在http://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( 'http://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鑑權的更多信息,請參考聲網文檔中心: http://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: http://github.com/Meherdeep/Interactive-Broadcasting
獲取更多教程、Demo、技術幫助,請點擊「閲讀原文」訪問聲網開發者社區。
- BFE開源項目2021年回顧和致謝
- 三大核心能力,揭示全面釋放數據價值的獨門祕訣
- 技術升級!國內公有云廠商首個支持保留消息功能
- 巧用 CSS 實現動態線條 Loading 動畫
- CTF&爬蟲:掌握這些特徵,一秒識別密文加密方式
- 一鍵AI着色,黑白老照片畫面瞬間鮮活
- 可觀測領域準獨角獸「駐雲科技」完成2億元新一輪融資
- 【微信小程序雲開發】1分鐘學會實現上傳、下載、預覽、刪除圖片,並且以九宮格展示圖片
- 強強聯袂!騰訊雲TDSQL與國雙戰略簽約,錨定國產數據庫巨大市場
- 如何優雅地讀寫HttpServletRequest和HttpServletResponse的請求體
- 優艾智合完成B系列超3億元人民幣融資 加速移動機器人規模化落地
- 5個很少被提到但能提高NLP工作效率的Python庫
- 徹底理解Golang Slice
- 全新的 Vue3 狀態管理工具:Pinia
- 想給用户天涯若比鄰的體驗?業務全球化面臨的三重挑戰
- 設計模式【4】-- 建造者模式詳解
- 分佈式鎖及其實現
- TDSQL | 國產化浪潮下,數據庫 雲如何跑上核心業務?
- 為了生成唯一id,React18專門引入了新Hook:useId
- Java SPI機制從原理到實戰