Miracast技术详解(一):Wi-Fi Display
Miracast概述
Miracast
Miracast是由Wi-Fi联盟于2012年所制定,以Wi-Fi直连(Wi-Fi Direct)为基础的无线显示标准。支持此标准的消费性电子产品(又称3C设备)可透过无线方式分享视频画面,例如手机可透过Miracast将影片或照片直接在电视或其他设备播放而无需任何连接线,也不需透过无线热点(AP,Access Point)。
Wi-Fi Direct
Wi-Fi直连(英语:Wi-Fi Direct),之前曾被称为Wi-Fi点对点(Wi-Fi Peer-to-Peer),是一套无线网络互连协议,让wifi设备可以不必透过无线网络接入点(Access Point),以点对点的方式,直接与另一个wifi设备连线,进行高速数据传输。这个协议由Wi-Fi联盟发展、支持与授与认证,通过认证的产品将可获得Wi-Fi CERTIFIED Wi-Fi Direct®标志。
Wi-Fi Display
Wi-Fi Display是Wi-Fi联盟制定的一个标准协议,它结合了Wi-Fi标准和H.264视频编码技术。利用这种技术,消费者可以从一个移动设备将音视频内容实时镜像到大型屏幕,随时、随地、在各种设备之间可靠地传输和观看内容。
Miracast实际上就是Wi-Fi联盟对支持WiFi Display功能的设备的认证名称,产品通过认证后会打上Miracast标签。
Sink & Source
如下图所示,Miracast可分为发送端与接收端。Source端为Miracast音视频数据发送端,负责音视频数据的采集、编码及发送。而Sink端为Miracast业务的接收端,负责接收Source端的音视频码流并解码显示,其中通过Wi-Fi Direct技术进行连接。
Android上Wi-Fi Direct的实现
上面的概述里面也说到,Miracast是基于Wi-Fi Direct技术来实现连接与数据传输。那么要实现Miracast技术,首先就得研究下Android平台下的Wi-Fi Direct技术。
Wi-Fi P2P 简介
Wi-Fi Direct(在Android平台上也称Wi-Fi P2P),可以让具备相应硬件的Android 4.0(API 级别 14)或更高版本设备在没有AP的情况下,通过WLAN进行直接互联,使用这些 API,可以实现支持 WiFi P2P 的设备间相互发现和连接,从而获得比蓝牙连接更远距离的高速连接通信效果。
为了实现一个基础的WiFiP2P,大致分为如下部分:
- 权限申请
- 初始化WiFiP2P的相关对象
- 定义监听WiFiP2P的广播接收器
- 连接设备
关于WiFiP2P中的群组,大致分为如下部分:
- 创建群组
- 连接群组
- 移除群组
- 权限申请
权限申请
首先,在AndroidManifest.xml中,对WiFi相关权限进行静态申请:
<uses-sdk android:minSdkVersion="14" /> <!-- WiFi相关权限 --> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
若后续还需要读写权限则添加:
<!-- 读写权限 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
然后,在 Android 6.0 及更高版本中,部分危险权限(Dangerous Permissions)权限需要在运行时请求用户批准(动态申请):
private void checkPermission() { String[] permissions = new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.ACCESS_FINE_LOCATION }; for (String permission : permissions) { if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { Log.i(TAG, permission + " granted."); } else { ActivityCompat.requestPermissions(this, permissions, 0); Log.w(TAG, permission + " not granted."); } } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { if (requestCode == 0) { for (int result : grantResults) { if (result == PackageManager.PERMISSION_GRANTED) { continue; } else { Toast.makeText(this, "权限未获取", Toast.LENGTH_SHORT).show(); } } } super.onRequestPermissionsResult(requestCode, permissions, grantResults); }
初始化
首先,需要创建:
- WifiP2pManager 对象
- WifiP2pManager.Channel 对象
- WiFiDirectBroadcastReceiver 对象(稍后介绍该广播接收器的定义)
- IntentFilter 对象
private WifiP2pManager mManager; private WifiP2pManager.Channel mChannel; private WiFiDirectBroadcastReceiver mReceiver; private IntentFilter mIntentFilter; private void initWifip2pHelper() { // 创建 WifiP2pManager 对象 mManager = (WifiP2pManager) getSystemService(WIFI_P2P_SERVICE); // 创建 WifiP2pManager.Channel 对象 mChannel = mManager.initialize(this, Looper.getMainLooper(), new WifiP2pManager.ChannelListener() { @Override public void onChannelDisconnected() { Log.i(TAG, "onChannelDisconnected: "); } }); // 创建 WiFiDirectBroadcastReceiver 对象 mReceiver = new WiFiDirectBroadcastReceiver(mManager, mChannel, this); // 创建 IntentFilter 对象 mIntentFilter = new IntentFilter(); mIntentFilter.addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION); mIntentFilter.addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION); mIntentFilter.addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION); mIntentFilter.addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION); mIntentFilter.addAction(WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION); }
其中, WiFiDirectBroadcastReceiver 对象想要监听的广播,与 IntentFilter 对象添加的action相同。
然后,在 Activity 的onResume()方法中注册广播接收器,在 Activity 的onPause()方法中取消注册该广播接收器:
/* register the broadcast receiver with the intent values to be matched */ @Override protected void onResume() { super.onResume(); registerReceiver(mReceiver, mIntentFilter); } /* unregister the broadcast receiver */ @Override protected void onPause() { super.onPause(); unregisterReceiver(mReceiver); }
定义监听WiFiP2P的广播接收器
监听WiFiP2P的广播接收器 WiFiDirectBroadcastReceiver 类具体定义如下:
public class WiFiDirectBroadcastReceiver extends BroadcastReceiver { private static final String TAG = "WiFiDirectBroadcastReceiver"; private WifiP2pManager mManager; private WifiP2pManager.Channel mChannel; private Wifip2pActivity mActivity; private List<WifiP2pDevice> mWifiP2pDeviceList = new ArrayList<>(); WifiP2pManager.PeerListListener mPeerListListener = new WifiP2pManager.PeerListListener() { @Override public void onPeersAvailable(WifiP2pDeviceList wifiP2pDeviceList) { mWifiP2pDeviceList.clear(); mWifiP2pDeviceList.addAll(wifiP2pDeviceList.getDeviceList()); } }; /** * 构造方法 * * @param manager WifiP2pManager对象 * @param channel WifiP2pManager.Channel对象 * @param activity Wifip2pActivity 对象 */ public WiFiDirectBroadcastReceiver(WifiP2pManager manager, WifiP2pManager.Channel channel, Wifip2pActivity activity) { super(); this.mManager = manager; this.mChannel = channel; this.mActivity = activity; } @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); switch (action) { case WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION: // Check to see if Wi-Fi is enabled and notify appropriate activity Log.i(TAG, "onReceive: WIFI_P2P_STATE_CHANGED_ACTION"); int state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1); if (state == WifiP2pManager.WIFI_P2P_STATE_ENABLED) { // Wifi P2P is enabled Log.i(TAG, "onReceive: Wifi P2P is enabled"); mManager.discoverPeers(mChannel, new WifiP2pManager.ActionListener() { @Override public void onSuccess() { Log.d(TAG, "onSuccess: "); } @Override public void onFailure(int i) { Log.d(TAG, "onFailure: "); } }); } else { // Wi-Fi P2P is not enabled Log.i(TAG, "onReceive: Wi-Fi P2P is not enabled"); } break; case WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION: // Call WifiP2pManager.requestPeers() to get a list of current peers Log.i(TAG, "onReceive: WIFI_P2P_PEERS_CHANGED_ACTION"); if (mManager == null) { return; } mManager.requestPeers(mChannel, mPeerListListener); break; case WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION: // Respond to new connection or disconnections Log.i(TAG, "onReceive: WIFI_P2P_CONNECTION_CHANGED_ACTION"); // NetworkInfo NetworkInfo networkInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO); // WifiP2pInfo WifiP2pInfo wifiP2pInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO); // WifiP2pGroup WifiP2pGroup wifiP2pGroup = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP); if (networkInfo.isConnected()) { if (wifiP2pInfo.isGroupOwner) { Toast.makeText(mActivity, "设备连接,本设备为GO", Toast.LENGTH_LONG).show(); } else { Toast.makeText(mActivity, "设备连接,本设备非GO", Toast.LENGTH_LONG).show(); } } else { Toast.makeText(mActivity, "设备断开", Toast.LENGTH_LONG).show(); } break; case WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION: // Respond to this device's wifi state changing Log.i(TAG, "onReceive: WIFI_P2P_THIS_DEVICE_CHANGED_ACTION"); WifiP2pDevice device = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE); Log.d(TAG, "onReceive: " +device.deviceAddress); break; case WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION: Log.i(TAG, "onReceive: WIFI_P2P_DISCOVERY_CHANGED_ACTION"); break; } } }
上述广播接收器用于监听系统关于WiFiP2P相关的广播。通常在onReceive()方法中,通过intent.getAction()方法获取到action,并根据action去匹配不同的关于WiFiP2P相关的广播,分别为:
- WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION
- WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION
- WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION
- WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION
- WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION
WIFI_P2P_STATE_CHANGED_ACTION:WiFiP2P状态发生改变时的广播
WiFiP2P具体有两个状态:
- WifiP2pManager.WIFI_P2P_STATE_ENABLED:可用
- WifiP2pManager.WIFI_P2P_STATE_DISABLED:不可用
而该状态的获取是由:
int state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1);
当WiFiP2P状态为可用时,调用discoverPeers()方法开始搜索附近WiFiP2P设备:
mManager.discoverPeers(mChannel, new WifiP2pManager.ActionListener() { @Override public void onSuccess() { Log.d(TAG, "onSuccess: "); } @Override public void onFailure(int i) { Log.d(TAG, "onFailure: "); } });
WIFI_P2P_PEERS_CHANGED_ACTION:发现附近WiFiP2P设备时的广播
当搜索发现附近存在WiFiP2P设备时,调用requestPeers()方法开始获取附近WiFiP2P设备列表:
mManager.requestPeers(mChannel, mPeerListListener);
当成功获取附近WiFiP2P设备列表后,会回调侦听器 WifiP2pManager.PeerListListener 中的onPeersAvailable()方法,并传递一个 WifiP2pDeviceList 对象作为参数,可以用一个 List
WifiP2pManager.PeerListListener mPeerListListener = new WifiP2pManager.PeerListListener() { @Override public void onPeersAvailable(WifiP2pDeviceList wifiP2pDeviceList) { mWifiP2pDeviceList.clear(); mWifiP2pDeviceList.addAll(wifiP2pDeviceList.getDeviceList()); } };
WIFI_P2P_CONNECTION_CHANGED_ACTION:连接状态发生改变时的广播
当连接状态发生改变时(如连接了一个设备,断开了一个设备),都会接收到该广播。当接收到该广播后,可以使用intent.getParcelableExtra()方法分别获取到 NetworkInfo , WifiP2pInfo , WifiP2pGroup 对象:
// 获取 NetworkInfo 对象 NetworkInfo networkInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO); // 获取 WifiP2pInfo 对象 WifiP2pInfo wifiP2pInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO); // 获取 WifiP2pGroup 对象 WifiP2pGroup wifiP2pGroup = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP);
获取到上述对象后,可以使用networkInfo.isConnected()方法来判断连接状态具体是“设备连接”还是“设备断开”,还可以根据wifiP2pInfo.isGroupOwner的值来判断设备是否为GroupOwner:
if (networkInfo.isConnected()) { if (wifiP2pInfo.isGroupOwner) { Toast.makeText(mActivity, "设备连接,本设备为GroupOwner", Toast.LENGTH_LONG).show(); } else { Toast.makeText(mActivity, "设备连接,本设备非GroupOwner", Toast.LENGTH_LONG).show(); } } else { Toast.makeText(mActivity, "设备断开", Toast.LENGTH_LONG).show(); }
WIFI_P2P_THIS_DEVICE_CHANGED_ACTION:当前设备状态发生改变时的广播
通常可以在这个广播中获取到当前设备的信息:
WifiP2pDevice device = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE);
WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION:搜索状态发生改变时的广播
启动搜索:
mManager.discoverPeers(mChannel, new WifiP2pManager.ActionListener() { @Override public void onSuccess() { Log.d(TAG, "onSuccess: "); } @Override public void onFailure(int i) { Log.d(TAG, "onFailure: "); } });
停止搜索:
mManager.stopPeerDiscovery(mChannel, new WifiP2pManager.ActionListener() { @Override public void onSuccess() { Log.d(TAG, "onSuccess: "); } @Override public void onFailure(int i) { Log.d(TAG, "onFailure: "); } });
连接设备
首先,选择一个需要连接的设备,并获取到该设备的 WifiP2pDevice 对象。然后,判断该设备的状态,设备状态通常为三种:
- WifiP2pDevice.AVAILABLE:可连接
- WifiP2pDevice.CONNECTED:已连接
- WifiP2pDevice.INVITED:已请求连接
根据不同的设备状态,进行不同的具体逻辑:
@Override public void onClick(View view) { // 获取到该设备的 WifiP2pDevice 对象 WifiP2pDevice wifiP2pDevice = mWifiP2pDeviceList.get(viewHolder.getAdapterPosition()); // 判断该设备的状态 switch (wifiP2pDevice.status) { case WifiP2pDevice.AVAILABLE: // 请求连接 WifiP2pConfig config = new WifiP2pConfig(); config.deviceAddress = wifiP2pDevice.deviceAddress; mManager.connect(mChannel, config, new WifiP2pManager.ActionListener() { @Override public void onSuccess() { Log.i(TAG, "connect success."); } @Override public void onFailure(int i) { Log.i(TAG, "connect failed."); } }); break; case WifiP2pDevice.CONNECTED: // 断开连接 mManager.removeGroup(mChannel, new WifiP2pManager.ActionListener() { @Override public void onSuccess() { Log.i(TAG, "removeGroup success."); } @Override public void onFailure(int i) { Log.i(TAG, "removeGroup failed."); } }); break; case WifiP2pDevice.INVITED: // 关闭连接请求 mManager.cancelConnect(mChannel, new WifiP2pManager.ActionListener() { @Override public void onSuccess() { Log.i(TAG, "cancelConnect success."); } @Override public void onFailure(int i) { Log.i(TAG, "cancelConnect failed."); } }); break; } }
Wi-Fi P2P 连接
在发送端搜索到Miracast设备,并点击对应设备后,就进入到了连接过程。此时Sink端应该会弹出一个[连接邀请]的授权窗口,可以选择拒绝或者接受。选择接受后,若是第一次连接,则会进入到GO协商的过程。
GO协商(Group Owner Negotiation)
GO协商是一个复杂的过程,共包含三个类型的Action帧:GO Req、GO Resp、GO Confirm,经过这几个帧的交互最终确认是Sink端还是Source端作为Group Owner,因此谁做GO是不确定的。那具体的协商规则是怎样的呢?官方的流程图清晰地给出了答案:
首先通过Group Owner Intent
的值进行协商,值大者为GO。若Intent值相同就需要判断Req帧中Tie breaker
位,置1者为GO。若2台设备都设置了Intent为最大值,都希望能成为GO,则这次协商失败。
那么,如何设置这个Intent值呢?发送端在connect()
的时候,可通过groupOwnerIntent
字段设置GO的优先级的(范围从0-15,0表示最小优先级),方法如下:
WifiP2pConfig config = new WifiP2pConfig(); ... config.groupOwnerIntent = 15; // I want this device to become the owner mManager.connect(mChannel, config, actionListener);
Miracast Sink端的场景为接收端,因此不能通过groupOwnerIntent
字段来设置GO优先级。那么还有其他方式可以让Sink端成为GO吗?毕竟在多台设备通过Miracast投屏的时候,Sink端是必须作为GO才能实现的。答案其实也很简单,就是自己创建一个组,自己成为GO,让其他Client加进来,在连接前直接调用createGroup()
方法即可完成建组操作:
mManager.createGroup(mChannel, new WifiP2pManager.ActionListener() { @Override public void onSuccess() { Log.d(TAG, "createGroup onSuccess"); } @Override public void onFailure(int reason) { Log.d(TAG, "createGroup onFailure:" + reason); } });
建组成功后我们可以通过requestGroupInfo()
方法来查看组的基本信息,以及组内Client的情况:
mManager.requestGroupInfo(mChannel, wifiP2pGroup -> { Log.d(TAG, "onGroupInfoAvailable detail:\n" + wifiP2pGroup.toString()); Collection<WifiP2pDevice> clientList = wifiP2pGroup.getClientList(); if (clientList != null) { int size = clientList.size(); Log.d(TAG, "onGroupInfoAvailable - client count:" + size); // Handle all p2p client devices } });
GO协商完毕,并且Wi-Fi Direct
连接成功的时候,我们将会收到WIFI_P2P_CONNECTION_CHANGED_ACTION
这个广播,此时我们可以调用requestConnectionInfo()
,并在onConnectionInfoAvailable()
回调中通过isGroupOwner
字段来判断当前设备是Group Owner,还是Peer。通过groupOwnerAddress
,我们可以很方便的获取到Group Owner的IP地址。
@Override public void onConnectionInfoAvailable(WifiP2pInfo wifiP2pInfo) { if (wifiP2pInfo.groupFormed && wifiP2pInfo.isGroupOwner) { Log.d(TAG, "is groupOwner: "); } else if (wifiP2pInfo.groupFormed) { Log.d(TAG, "is peer: "); } String ownerIP = wifiP2pInfo.groupOwnerAddress.getHostAddress(); Log.d(TAG, "onConnectionInfoAvailable ownerIP = " + ownerIP); }
受WiFi P2P API的限制,各设备获取到的MAC和IP地址情况如下图所示:
由于在后续RTSP进行指令通讯的时候,需要通过Socket与Source端建立连接,也就是我们需要先知道Source端的IP地址与端口。根据上图,我们可能出现以下2种情况:
-
情况1:Sink端为Peer,Source端为GO。
这种情况下,Sink端知道Source端(GO)的IP地址,可以直接进行Socket连接。 -
情况2:Sink端为GO,Source端为Peer。
这种情况下,Sink端只知道自己(GO)的IP地址,不知道Source端(Peer)的IP地址,但此时能获取到MAC地址。
通过ARP协议获取对应MAC设备的IP地址
针对上述情况2,我们需要通过MAC地址获取到对应主机的IP地址,以完成与Source端的Socket连接,比较经典的方案是采用解析ARP缓存表的形式进行。
ARP(Address Resolution Protocol),即地址解析协议,是根据IP地址获取物理地址的一个TCP/IP协议。主机发送信息时将包含目标IP地址的ARP请求广播到局域网络上的所有主机,并接收返回消息,以此确定目标的物理地址;收到返回消息后将该IP地址和物理地址存入本机ARP缓存中并保留一定时间,下次请求时直接查询ARP缓存以节约资源。
在Android上,我们可以通过以下指令获取ARP缓存表:
方法1:通过busybox arp指令
dior:/ $ busybox arp ? (192.168.0.108) at f8:ff:c2:10:e7:62 [ether] on wlan0 ? (192.168.0.1) at 9c:a6:15:d6:e8:f4 [ether] on wlan0
方法2:通过cat proc/net/arp命令
dior:/ $ cat proc/net/arp IP address HW type Flags HW address Mask Device 192.168.0.108 0x1 0x2 f8:ff:c2:10:e7:62 * wlan0 192.168.0.1 0x1 0x2 9c:a6:15:d6:e8:f4 * wlan0
剩下的工作就是采用强大的正则表达式解析返回的字符串,并查找出对应MAC设备的IP地址了。
获取Source端RTSP端口号
经过上面的步骤,我们已经拿到了Source端的IP地址,只剩下端口号了。这一步就比较简单了,通过requestPeers()
方法获取已连接的对等设备WifiP2pDevice
,再获取其中的WifiP2pWfdInfo
即可拿到端口号:
mManager.requestPeers(mChannel, peers -> { Collection<WifiP2pDevice> devices = peers.getDeviceList(); for (WifiP2pDevice device : devices) { boolean isConnected = (WifiP2pDevice.CONNECTED == device.status); if (isConnected) { int port = getDevicePort(device); break; } } });
这里由于WifiP2pDevice
中的wfdInfo
字段为@hide
,因此需要通过反射的方式获取WifiP2pWfdInfo
。最后通过getControlPort()
方法即可拿到Source端RTSP端口号:
public int getDevicePort(WifiP2pDevice device) { int port = WFD_DEFAULT_PORT; try { Field field = ReflectUtil.getPrivateField(device.getClass(), "wfdInfo"); if (field == null) { return port; } WifiP2pWfdInfo wfdInfo = (WifiP2pWfdInfo) field.get(device); if (wfdInfo != null) { port = wfdInfo.getControlPort(); if (port == 0) { Log.w(TAG,"set port to WFD_DEFAULT_PORT"); port = WFD_DEFAULT_PORT; } } } catch (IllegalAccessException e) { e.printStackTrace(); } return port; }
拿到了Source端的IP地址与端口号后,我们就可以建立RTSP连接,建立后续控制指令的通道了,详见下篇博客。
参考文档
WLAN 直连(对等连接或 P2P)概览
通过 Wi-Fi 直连创建点对点连接
Android WiFi P2P开发实践笔记
wifi直连(Android)Wifi-Direct
「Android」WiFiP2P入门
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 【.NET】调用本地 Deepseek 模型
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库