重新想象 Windows 8 Store Apps (68) - 后台任务: 控制通道(ControlChannel)

简介: 原文:重新想象 Windows 8 Store Apps (68) - 后台任务: 控制通道(ControlChannel)[源码下载] 重新想象 Windows 8 Store Apps (68) - 后台任务: 控制通道(ControlChannel) 作者:webabcd介绍重新想象 W...
原文: 重新想象 Windows 8 Store Apps (68) - 后台任务: 控制通道(ControlChannel)

[源码下载]


重新想象 Windows 8 Store Apps (68) - 后台任务: 控制通道(ControlChannel)



作者:webabcd


介绍
重新想象 Windows 8 Store Apps 之 后台任务

  • 控制通道(ControlChannel)



示例
1、客户端与服务端做 ControlChannel 通信的关键代码
ControlChannelHelper/AppContext.cs

/*
 * 本例通过全局静态变量来实现 app 与 task 的信息共享,以便后台任务可以获取到 app 中的相关信息
 * 
 * 注:
 * 也可以通过 Windows.ApplicationModel.Core.CoreApplication.Properties 保存数据,以实现 app 与 task 的信息共享
 */

using System.Collections.Concurrent;
using Windows.Networking.Sockets;

namespace ControlChannelHelper
{
    public class AppContext
    {
        /// <summary>
        /// 从 ControlChannel 接收到的数据
        /// </summary>
        public static ConcurrentQueue<string> MessageQueue = new ConcurrentQueue<string>();

        /// <summary>
        /// 客户端 socket
        /// </summary>
        public static StreamSocket ClientSocket;
    }
}

ControlChannelHelper/SocketControlChannel.cs

/*
 * 实现一个 socket tcp 通信的 ControlChannel,client 将在此 ControlChannel 中实时接收数据
 * 
 * 注:
 * win8 client 和 socket server 不能部署在同一台机器上,否则会抛出异常:{参考的对象类型不支持尝试的操作。 (异常来自 HRESULT:0x8007273D)}
 */

using System;
using System.Threading.Tasks;
using Windows.ApplicationModel.Background;
using Windows.Foundation;
using Windows.Networking;
using Windows.Networking.Sockets;
using Windows.Storage.Streams;

namespace ControlChannelHelper
{
    public class SocketControlChannel : IDisposable
    {
        // ControlChannel
        public ControlChannelTrigger Channel { get; set; }

        // 客户端 socket
        private StreamSocket _socket;
        // 用于发送数据
        private DataWriter _dataWriter;
        // 用于接收数据
        private DataReader _dataReader;

        // 向服务端发送心跳的间隔时间,单位为分钟,最小 15 分钟
        private uint _serverKeepAliveInterval = 15;
        // ControlChannel 的标识
        private string _channelId = "myControlChannel";

        public SocketControlChannel()
        {

        }

        public async Task<string> CreateChannel()
        {
            Dispose();

            try
            {
                // 实例化一个 ControlChannel
                Channel = new ControlChannelTrigger(_channelId, _serverKeepAliveInterval, ControlChannelTriggerResourceType.RequestHardwareSlot);
            }
            catch (Exception ex)
            {
                Dispose();
                return "控制通道创建失败:" + ex.ToString();
            }

            // 注册用于向服务端 socket 发送心跳的后台任务,需要在 manifest 中做相关配置
            var keepAliveBuilder = new BackgroundTaskBuilder();
            keepAliveBuilder.Name = "myControlChannelKeepAlive";
            // 注:如果走的是 WebSocket 协议,则系统已经为其内置了发送心跳的逻辑,此处直接指定为 Windows.Networking.Sockets.WebSocketKeepAlive 即可
            keepAliveBuilder.TaskEntryPoint = "BackgroundTaskLib.ControlChannelKeepAlive";
            keepAliveBuilder.SetTrigger(Channel.KeepAliveTrigger); // 到了发送心跳的间隔时间时则触发,本例是 15 分钟
            keepAliveBuilder.Register();

            // 注册用于向用户显示通知的后台任务,需要在 manifest 中做相关配置
            var pushNotifyBuilder = new BackgroundTaskBuilder();
            pushNotifyBuilder.Name = "myControlChannelPushNotification";
            pushNotifyBuilder.TaskEntryPoint = "BackgroundTaskLib.ControlChannelPushNotification";
            pushNotifyBuilder.SetTrigger(Channel.PushNotificationTrigger); // 在 ControlChannel 中收到了推送过来的数据时则触发
            pushNotifyBuilder.Register();

            try
            {
                _socket = new StreamSocket();
                AppContext.ClientSocket = _socket;

                // 在 ControlChannel 中通过指定的 StreamSocket 通信
                Channel.UsingTransport(_socket);

                // client socket 连接 server socket
                await _socket.ConnectAsync(new HostName("192.168.6.204"), "3366");

                // 开始等待 ControlChannel 中推送过来的数据,如果 win8 client 和 socket server 部署在同一台机器上,则此处会抛出异常
                ControlChannelTriggerStatus status = Channel.WaitForPushEnabled();

                if (status != ControlChannelTriggerStatus.HardwareSlotAllocated && status != ControlChannelTriggerStatus.SoftwareSlotAllocated)
                    return "控制通道创建失败:" + status.ToString();

                // 发送数据到服务端
                _dataWriter = new DataWriter(_socket.OutputStream);
                string message = "hello " + DateTime.Now.ToString("hh:mm:ss") + "^";
                _dataWriter.WriteString(message);
                await _dataWriter.StoreAsync();

                // 接收数据
                ReceiveData();
            }
            catch (Exception ex)
            {
                Dispose();
                return "控制通道创建失败:" + ex.ToString();
            }

            return "ok";
        }

        // 开始接收此次数据
        private void ReceiveData()
        {
            uint maxBufferLength = 256;

            try
            {
                var buffer = new Windows.Storage.Streams.Buffer(maxBufferLength);
                var asyncOperation = _socket.InputStream.ReadAsync(buffer, maxBufferLength, InputStreamOptions.Partial);
                asyncOperation.Completed = (IAsyncOperationWithProgress<IBuffer, uint> asyncInfo, AsyncStatus asyncStatus) =>
                {
                    switch (asyncStatus)
                    {
                        case AsyncStatus.Completed:
                        case AsyncStatus.Error:
                            try
                            {
                                IBuffer bufferRead = asyncInfo.GetResults();
                                uint bytesRead = bufferRead.Length;
                                _dataReader = DataReader.FromBuffer(bufferRead);

                                // 此次数据接收完毕
                                ReceiveCompleted(bytesRead);
                            }
                            catch (Exception ex)
                            {
                                AppContext.MessageQueue.Enqueue(ex.ToString());
                            }
                            break;
                        case AsyncStatus.Canceled:
                            AppContext.MessageQueue.Enqueue("接收数据时被取消了");
                            break;
                    }
                };
            }
            catch (Exception ex)
            {
                AppContext.MessageQueue.Enqueue(ex.ToString());
            }
        }

        public void ReceiveCompleted(uint bytesRead)
        {
            // 获取此次接收到的数据
            uint bufferLength = _dataReader.UnconsumedBufferLength;
            string message = _dataReader.ReadString(bufferLength);

            // 将接收到的数据放到内存中,由 PushNotificationTrigger 触发的后台任进行处理(当然也可以在此处处理)
            AppContext.MessageQueue.Enqueue(message);

            // 开始接收下一次数据
            ReceiveData();
        }

        // 释放资源
        public void Dispose()
        {
            lock (this)
            {
                if (_dataWriter != null)
                {
                    try
                    {
                        _dataWriter.DetachStream();
                        _dataWriter = null;
                    }
                    catch (Exception ex)
                    {

                    }
                }

                if (_dataReader != null)
                {
                    try
                    {
                        _dataReader.DetachStream();
                        _dataReader = null;
                    }
                    catch (Exception exp)
                    {

                    }
                }

                if (_socket != null)
                {
                    _socket.Dispose();
                    _socket = null;
                }

                if (Channel != null)
                {
                    Channel.Dispose();
                    Channel = null;
                }
            }
        }
    }
}


2、客户端辅助类
BackgroundTaskLib/ControlChannelKeepAlive.cs

/*
 * 用于向服务端 socket 发送心跳的后台任务
 * 
 * 注:
 * 如果走的是 WebSocket 协议,则系统已经为其内置了发送心跳的逻辑
 * 只需要将 BackgroundTaskBuilder.TaskEntryPoint 设置为 Windows.Networking.Sockets.WebSocketKeepAlive 即可,而不需要再自定义此后台任务
 */

using ControlChannelHelper;
using System;
using Windows.ApplicationModel.Background;
using Windows.Networking.Sockets;
using Windows.Storage.Streams;

namespace BackgroundTaskLib
{
    public sealed class ControlChannelKeepAlive : IBackgroundTask
    {
        public void Run(IBackgroundTaskInstance taskInstance)
        {
            if (taskInstance == null)
                return;

            // 获取 ControlChannel
            var channelEventArgs = taskInstance.TriggerDetails as IControlChannelTriggerEventDetails;
            ControlChannelTrigger channel = channelEventArgs.ControlChannelTrigger;

            if (channel == null)
                return;

            string channelId = channel.ControlChannelTriggerId;

            // 发送心跳
            SendData();
        }

        private async void SendData()
        {
            // 发送心跳到 server socket
            DataWriter dataWriter = new DataWriter(AppContext.ClientSocket.OutputStream);
            string message = "hello " + DateTime.Now.ToString("hh:mm:ss") + "^";
            dataWriter.WriteString(message);
            await dataWriter.StoreAsync();
        }
    }
}

BackgroundTaskLib/ControlChannelPushNotification.cs

/*
 * 用于向用户显示通知的后台任务,需要在 manifest 中做相关配置
 */

using ControlChannelHelper;
using NotificationsExtensions.ToastContent;
using System;
using Windows.ApplicationModel.Background;
using Windows.Networking.Sockets;
using Windows.UI.Notifications;

namespace BackgroundTaskLib
{
    public sealed class ControlChannelPushNotification : IBackgroundTask
    {
        public void Run(IBackgroundTaskInstance taskInstance)
        {
            if (taskInstance == null)
                return;

            // 获取 ControlChannel
            var channelEventArgs = taskInstance.TriggerDetails as IControlChannelTriggerEventDetails;
            ControlChannelTrigger channel = channelEventArgs.ControlChannelTrigger;

            if (channel == null)
                return;

            string channelId = channel.ControlChannelTriggerId;

            try
            {
                string messageReceived;

                // 将从 ControlChannel 中接收到的信息,以 toast 的形式弹出
                while (AppContext.MessageQueue.Count > 0)
                {
                    bool result = AppContext.MessageQueue.TryDequeue(out messageReceived);
                    if (result)
                    {
                        IToastText01 templateContent = ToastContentFactory.CreateToastText01();
                        templateContent.TextBodyWrap.Text = messageReceived;
                        templateContent.Duration = ToastDuration.Short; 
                        IToastNotificationContent toastContent = templateContent;
                        ToastNotification toast = toastContent.CreateNotification();

                        ToastNotifier toastNotifier = ToastNotificationManager.CreateToastNotifier();
                        toastNotifier.Show(toast);
                    }
                }
            }
            catch (Exception ex)
            {

            }
        }
    }
}


3、客户端
BackgroundTask/ControlChannel.xaml

<Page
    x:Class="XamlDemo.BackgroundTask.ControlChannel"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:XamlDemo.BackgroundTask"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">

    <Grid Background="Transparent">
        <StackPanel Margin="120 0 0 0">
            
            <TextBlock Name="lblMsg" FontSize="14.667" />
            
            <Button Name="btnCreateChannel" Content="创建一个 ControlChannel" Margin="0 10 0 0" Click="btnCreateChannel_Click" />
            
        </StackPanel>
    </Grid>
</Page>

BackgroundTask/ControlChannel.xaml.cs

/*
 * 演示如何创建一个基于 socket tcp 通信的 ControlChannel,client 将在此 ControlChannel 中实时接收数据
 * 
 * 注:
 * 不能在模拟器中运行
 * RTC - Real Time Communication 实时通信
 * win8 client 和 socket server 不能部署在同一台机器上,否则会抛出异常:{参考的对象类型不支持尝试的操作。 (异常来自 HRESULT:0x8007273D)}
 */

using System;
using ControlChannelHelper;
using Windows.ApplicationModel.Background;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Popups;

namespace XamlDemo.BackgroundTask
{
    public sealed partial class ControlChannel : Page
    {
        public ControlChannel()
        {
            this.InitializeComponent();
        }

        private async void btnCreateChannel_Click(object sender, RoutedEventArgs e)
        {
            // 如果 app 在锁屏上,则可以通过 ControlChannelTrigger 触发指定的后台任务
            BackgroundAccessStatus status = BackgroundExecutionManager.GetAccessStatus();
            if (status == BackgroundAccessStatus.Unspecified)
            {
                status = await BackgroundExecutionManager.RequestAccessAsync();
            }
            if (status == BackgroundAccessStatus.Denied)
            {
                await new MessageDialog("请先将此 app 添加到锁屏").ShowAsync();
                return;
            }

            // 创建一个基于 socket tcp 通信的 ControlChannel,相关代码参见:ControlChannelHelper 项目
            SocketControlChannel channel = new SocketControlChannel();
            string result = await channel.CreateChannel();

            lblMsg.Text = result;
        }
    }
}


4、服务端
SocketServerTcp/ClientSocketPacket.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace SocketServerTcp
{
    /// <summary>
    /// 对客户端 Socket 及其他相关信息做一个封装
    /// </summary>
    public class ClientSocketPacket
    {
        /// <summary>
        /// 客户端 Socket
        /// </summary>
        public System.Net.Sockets.Socket Socket { get; set; }

        private byte[] _buffer;
        /// <summary>
        /// 为该客户端 Socket 开辟的缓冲区
        /// </summary>
        public byte[] Buffer
        {
            get
            {
                if (_buffer == null)
                    _buffer = new byte[64];

                return _buffer;
            }
        }

        private List<byte> _receivedByte;
        /// <summary>
        /// 客户端 Socket 发过来的信息的字节集合
        /// </summary>
        public List<byte> ReceivedByte
        {
            get
            {
                if (_receivedByte == null)
                    _receivedByte = new List<byte>();

                return _receivedByte;
            }
        }
    }
}

SocketServerTcp/Main.cs

/*
 * 从以前写的 wp7 demo 中直接复制过来的,用于演示如何通过 ControlChannel 实时地将信息以 socket tcp 的方式推送到 win8 客户端
 * 
 * 注:
 * 本例通过一个约定结束符来判断是否接收完整,其仅用于演示,实际项目中请用自定义协议。可参见:XamlDemo/Communication/TcpDemo.xaml.cs
 */

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

using System.Net.Sockets;
using System.Net;
using System.Threading;
using System.IO;

namespace SocketServerTcp
{
    public partial class Main : Form
    {
        SynchronizationContext _syncContext;

        System.Timers.Timer _timer;

        // 信息结束符,用于判断是否完整地读取了客户端发过来的信息,要与客户端的信息结束符相对应(本例只用于演示,实际项目中请用自定义协议)
        private string _endMarker = "^";

        // 服务端监听的 socket
        private Socket _listener;

        // 实例化 ManualResetEvent,设置其初始状态为无信号
        private ManualResetEvent _signal = new ManualResetEvent(false);

        // 客户端 Socket 列表
        private List<ClientSocketPacket> _clientList = new List<ClientSocketPacket>();

        public Main()
        {
            InitializeComponent();

            // UI 线程
            _syncContext = SynchronizationContext.Current;

            // 启动后台线程去运行 Socket 服务
            Thread thread = new Thread(new ThreadStart(LaunchSocketServer));
            thread.IsBackground = true;
            thread.Start();
        }

        private void LaunchSocketServer()
        {
            // 每 10 秒运行一次计时器所指定的方法,群发信息
            _timer = new System.Timers.Timer();
            _timer.Interval = 10000d;
            _timer.Elapsed += new System.Timers.ElapsedEventHandler(_timer_Elapsed);
            _timer.Start();

            // TCP 方式监听 3366 端口
            _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            _listener.Bind(new IPEndPoint(IPAddress.Any, 3366));
            // 指定等待连接队列中允许的最大数
            _listener.Listen(10);


            while (true)
            {
                // 设置为无信号
                _signal.Reset();

                // 开始接受客户端传入的连接
                _listener.BeginAccept(new AsyncCallback(OnClientConnect), null);

                // 阻塞当前线程,直至有信号为止
                _signal.WaitOne();
            }
        }

        private void _timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
        {
            // 每 10 秒给所有连入的客户端发送一次消息
            SendData(string.Format("webabcd 对所有人说:大家好! 【信息来自服务端 {0}】", DateTime.Now.ToString("hh:mm:ss")));
        }

        private void OnClientConnect(IAsyncResult async)
        {
            ClientSocketPacket client = new ClientSocketPacket();
            // 完成接受客户端传入的连接的这个异步操作,并返回客户端连入的 socket
            client.Socket = _listener.EndAccept(async);

            // 将客户端连入的 Socket 放进客户端 Socket 列表
            _clientList.Add(client);

            OutputMessage(((IPEndPoint)client.Socket.LocalEndPoint).Address + " 连入了服务器");
            SendData("一个新的客户端已经成功连入服务器。。。 【信息来自服务端】");

            try
            {
                // 开始接收客户端传入的数据
                client.Socket.BeginReceive(client.Buffer, 0, client.Buffer.Length, SocketFlags.None, new AsyncCallback(OnDataReceived), client);
            }
            catch (SocketException ex)
            {
                // 处理异常
                HandleException(client, ex);
            }

            // 设置为有信号
            _signal.Set();
        }

        private void OnDataReceived(IAsyncResult async)
        {
            ClientSocketPacket client = async.AsyncState as ClientSocketPacket;

            int count = 0;

            try
            {
                // 完成接收数据的这个异步操作,并返回接收的字节数
                if (client.Socket.Connected)
                    count = client.Socket.EndReceive(async);
            }
            catch (SocketException ex)
            {
                HandleException(client, ex);
            }

            // 把接收到的数据添加进收到的字节集合内
            // 本例采用 UTF8 编码,中文占用 3 字节,英文等字符与 ASCII 相同
            foreach (byte b in client.Buffer.Take(count))
            {
                if (b == 0) continue; // 如果是空字节则不做处理('\0')

                client.ReceivedByte.Add(b);
            }

            // 把当前接收到的数据转换为字符串。用于判断是否包含自定义的结束符
            string receivedString = UTF8Encoding.UTF8.GetString(client.Buffer, 0, count);

            // 如果该 Socket 在网络缓冲区中没有排队的数据 并且 接收到的数据中有自定义的结束符时
            if (client.Socket.Connected && client.Socket.Available == 0 && receivedString.Contains(_endMarker))
            {
                // 把收到的字节集合转换成字符串(去掉自定义结束符)
                // 然后清除掉字节集合中的内容,以准备接收用户发送的下一条信息
                string content = UTF8Encoding.UTF8.GetString(client.ReceivedByte.ToArray());
                content = content.Replace(_endMarker, "");
                client.ReceivedByte.Clear();

                // 发送数据到所有连入的客户端,并在服务端做记录
                SendData(content);
                OutputMessage(content);
            }

            try
            {
                // 继续开始接收客户端传入的数据
                if (client.Socket.Connected)
                    client.Socket.BeginReceive(client.Buffer, 0, client.Buffer.Length, 0, new AsyncCallback(OnDataReceived), client);
            }
            catch (SocketException ex)
            {
                HandleException(client, ex);
            }
        }

        /// <summary>
        /// 发送数据到所有连入的客户端
        /// </summary>
        /// <param name="data">需要发送的数据</param>
        private void SendData(string data)
        {
            byte[] byteData = UTF8Encoding.UTF8.GetBytes(data);

            foreach (ClientSocketPacket client in _clientList)
            {
                if (client.Socket.Connected)
                {
                    try
                    {
                        // 如果某客户端 Socket 是连接状态,则向其发送数据
                        client.Socket.BeginSend(byteData, 0, byteData.Length, SocketFlags.None, new AsyncCallback(OnDataSent), client);
                    }
                    catch (SocketException ex)
                    {
                        HandleException(client, ex);
                    }
                }
                else
                {
                    // 某 Socket 断开了连接的话则将其关闭,并将其清除出客户端 Socket 列表
                    // 也就是说每次向所有客户端发送消息的时候,都会从客户端 Socket 集合中清除掉已经关闭了连接的 Socket
                    client.Socket.Close();
                    _clientList.Remove(client);
                }
            }
        }

        private void OnDataSent(IAsyncResult async)
        {
            ClientSocketPacket client = async.AsyncState as ClientSocketPacket;

            try
            {
                // 完成将信息发送到客户端的这个异步操作
                int sentBytesCount = client.Socket.EndSend(async);
            }
            catch (SocketException ex)
            {
                HandleException(client, ex);
            }
        }

        /// <summary>
        /// 处理 SocketException 异常
        /// </summary>
        /// <param name="client">导致异常的 ClientSocketPacket</param>
        /// <param name="ex">SocketException</param>
        private void HandleException(ClientSocketPacket client, SocketException ex)
        {
            // 在服务端记录异常信息,关闭导致异常的 Socket,并将其清除出客户端 Socket 列表
            OutputMessage(client.Socket.RemoteEndPoint.ToString() + " - " + ex.Message);
            client.Socket.Close();
            _clientList.Remove(client);
        }

        // 在 UI 上输出指定信息
        private void OutputMessage(string data)
        {
            _syncContext.Post((p) => { txtMsg.Text += p.ToString() + "\r\n"; }, data);
        }
    }
}



OK
[源码下载]

目录
相关文章
|
11月前
|
C# Windows
【Azure App Service】在App Service for Windows上验证能占用的内存最大值
根据以上测验,当使用App Service内存没有达到预期的值,且应用异常日志出现OutOfMemory时,就需要检查Platform的设置是否位64bit。
176 11
|
Java 应用服务中间件 开发工具
[App Service for Windows]通过 KUDU 查看 Tomcat 配置信息
[App Service for Windows]通过 KUDU 查看 Tomcat 配置信息
150 2
|
Java 应用服务中间件 Windows
【App Service for Windows】为 App Service 配置自定义 Tomcat 环境
【App Service for Windows】为 App Service 配置自定义 Tomcat 环境
130 2
|
PHP Windows
【Azure App Service for Windows】 PHP应用出现500 : The page cannot be displayed because an internal server error has occurred. 错误
【Azure App Service for Windows】 PHP应用出现500 : The page cannot be displayed because an internal server error has occurred. 错误
232 1
|
网络安全 API 数据安全/隐私保护
【Azure App Service】.NET代码实验App Service应用中获取TLS/SSL 证书 (App Service Windows)
【Azure App Service】.NET代码实验App Service应用中获取TLS/SSL 证书 (App Service Windows)
133 0
|
Shell PHP Windows
【Azure App Service】Web Job 报错 UNC paths are not supported. Defaulting to Windows directory.
【Azure App Service】Web Job 报错 UNC paths are not supported. Defaulting to Windows directory.
122 0
|
存储 Linux Windows
【应用服务 App Service】App Service For Windows 如何挂载Storage Account File Share 示例
【应用服务 App Service】App Service For Windows 如何挂载Storage Account File Share 示例
120 0
|
28天前
|
运维 安全 网络安全
Windows Server 2019拨号“找不到设备”?Error 1058解决指南
Windows Server 2019拨号报错1058?别急!这不是硬件故障,而是关键服务被禁用。通过“服务依存关系”排查,依次启动“安全套接字隧道协议”“远程接入连接管理”和“路由与远程访问”服务,仅需4步即可恢复PPPoE或VPN拨号功能,轻松解决网络中断问题。
127 1
|
29天前
|
存储 SQL 人工智能
Windows Server 2025 中文版、英文版下载 (2025 年 9 月更新)
Windows Server 2025 中文版、英文版下载 (2025 年 9 月更新)
682 3
Windows Server 2025 中文版、英文版下载 (2025 年 9 月更新)
|
1月前
|
安全 Unix 物联网
Windows 7 & Windows Server 2008 R2 简体中文版下载 (2025 年 9 月更新)
Windows 7 & Windows Server 2008 R2 简体中文版下载 (2025 年 9 月更新)
218 2

热门文章

最新文章