Media Foundation 入门:用 COM 视角理解 API

· · Media Foundation, COM, C++, Windows开发

刚开始接触 Media Foundation 时,很容易有这样的感觉:「明明是在用 Windows 的视频、音频 API,怎么突然冒出这么多 COM 相关的内容」。CoInitializeExMFStartupIMFSourceReaderIMFMediaTypeIMFTransformIMFActivateHRESULT、GUID 等概念一下子全部涌出来,气氛瞬间变得很像 Win32 / COM,反而让人看不清 Media Foundation 到底是什么。

本文不打算把 Media Foundation 像字典一样全面覆盖,而是聚焦在以下 3 点:

  • 为什么使用 Media Foundation 时会自然地涉及 COM
  • 哪些地方 COM 的色彩会特别浓
  • 最开始应该从 Source Reader / Sink Writer / Media Session / MFT 中的哪一个入手

代码示例基于 C++,但即便是通过 .NET 等语言经由封装层来使用,思路本身也基本相同。

目录

  1. 先说结论(一句话)
  2. 首先看这张整理表
    • 2.1. 想做什么时该碰什么
    • 2.2. 哪里会显出 COM 的一面
    • 2.3. 先记住的几个术语
  3. Media Foundation 的整体结构(图)
  4. Media Foundation 显出 COM 一面的地方
    • 4.1. 初始化时 CoInitializeExMFStartup 并存
    • 4.2. 对象的传递以接口为中心
    • 4.3. Activation Object 的出现
    • 4.4. 配置与类型信息以 IMFAttributes 和 GUID 为中心
    • 4.5. 异步、回调、线程的处理方式也很 COM
    • 4.6. 但 Media Foundation 并不等于 COM
  5. 大致的使用区分
    • 5.1. 先从 Source Reader 入手的情况
    • 5.2. 要写入文件就用 Sink Writer
    • 5.3. 需要播放和同步就用 Media Session
    • 5.4. 需要插入自定义部件就用 MFT
  6. 实务检查清单
  7. 代码摘录
    • 7.1. 初始化
    • 7.2. 以同步模式创建 Source Reader
    • 7.3. 以异步模式创建 Source Reader
    • 7.4. 用 MFTEnumEx 枚举并实例化 MFT
  8. 总结
  9. 参考资料

1. 先说结论(一句话)

  • Media Foundation 是用于处理视频和音频的平台,并不是说整个 API 直接等同于纯粹的 COM
  • 不过,source / transform / sink / activation / attributes / callback 之间的边界都是用 COM 接口表示的,因此使用过程中自然会涉及 IUnknownHRESULT、GUID、apartment
  • 先从 Source Reader / Sink Writer 入手,需要播放控制时再进阶到 Media Session,需要自定义转换器时再进阶到 MFT,这样理解起来会更顺畅

简而言之,Media Foundation 是一个媒体处理平台,COM 深深嵌入在它的边界处

先掌握这一点,理解「为什么会突然出现 COM 的一面」就会容易得多。

2. 首先看这张整理表

2.1. 想做什么时该碰什么

先看这张表,有助于选择入口。

想做的事 首先接触的对象 COM 浓度 补充
想从文件或摄像头中取出帧 / 采样 Source Reader 如果需要,还会帮你处理 decoder
想把生成的音频 / 视频写入文件 Sink Writer 如果需要,可以一并处理 encoder 和 media sink
想处理播放、停止、跳转、A/V 同步、质量控制 Media Session 需要理解 topology 与 session
想插入自定义的转换器或类似 codec 的部件 MFT IMFTransform 为核心来思考
想先看枚举出来的候选,再只实例化需要的那个 IMFActivate 有时返回的不是本体,而是 activation object

2.2. 哪里会显出 COM 的一面

位置 会出现什么 首先要理解的点
初始化 CoInitializeEx, MFStartup COM 初始化与 Media Foundation 初始化是两件不同的事
对象创建与传递 IMFSourceReader, IMFMediaType, IMFTransform 大多是接口指针 + HRESULT
配置 IMFAttributes, GUID 配置值和类型信息以 key/value + GUID 的形式表达
枚举与延迟创建 IMFActivate, ActivateObject 枚举结果本身有时并不是本体
异步 IMFSourceReaderCallback, work queue 需要意识到 callback 与 apartment 的关系
播放控制 topology, Media Session 整个管道的流转是 Media Foundation 特有的概念

2.3. 先记住的几个术语

术语 这里的含义
Media Source 把媒体数据送入管道的入口。可以是文件、网络、采集设备等
MFT Media Foundation Transform。解码器、编码器、视频转换器等的通用模型
Media Sink 媒体数据的去处。屏幕显示、音频输出、写入文件等
Media Session 管理整个管道流转的机制,负责播放和同步
Topology 表示 source / transform / sink 如何连接的拓扑图
Activation Object 用于稍后创建本体的辅助对象,由 IMFActivate 表示
Attributes 以 GUID 为键的 key/value 存储,在 Media Foundation 中被大量使用

提前掌握这些术语,读文档时的困惑会大幅减少。

3. Media Foundation 的整体结构(图)

从大局上看,Media Foundation 主要是 关于媒体管道的话题。 COM 固然重要,但先看整体结构会更容易理清脉络。

应用直接处理数据的模型Source Reader (+ decoder)Media Source应用程序Sink Writer (+ encoder)Media Sink使用整个管道的模型MFTMedia SourceMedia SinkMedia Session

Media Foundation 大致有以下两种使用方式:

  • 使用整个管道的模型
    • 连接 source / transform / sink,由 Media Session 管理数据流和 A/V 同步
  • 应用直接处理数据的模型
    • 用 Source Reader 从 source 取出数据,用 Sink Writer 写入 sink

后者在希望自己处理帧或采样数据的场景下更容易入手。 而如果希望连播放和同步都交给平台处理,前者才是正统做法。

需要牢记的是,Media Foundation 的本质是一个媒体处理平台,与直接操作一堆 COM 对象的感觉略有不同

不过,一旦开始关注这些部件之间的边界,COM 的色彩就会突然变浓。下一章会依次介绍这些位置。

4. Media Foundation 显出 COM 一面的地方

4.1. 初始化时 CoInitializeExMFStartup 并存

这是大多数人最先感到别扭的地方。在谈到打开文件、从摄像头取数据之前,首先出现的是 CoInitializeExMFStartup

  • CoInitializeEx 用于初始化 COM 库
  • MFStartup 用于初始化 Media Foundation 平台

也就是说,仅完成 COM 初始化是不够的,还需要单独初始化 Media Foundation。 到这里就能明白,「这不只是一个普通的视频 API,底层还嵌入了相当多基于 COM 的契约」。

在实务中,此时先确定好以下几点会让后续更轻松:

  • 由哪个线程使用 Media Foundation
  • 该线程设为 STA 还是 MTA
  • 由谁负责 MFStartup / MFShutdownCoInitializeEx / CoUninitialize 的调用职责

如果这部分设计一直含糊不清,之后在处理 callback 和 UI 联动时就会变得难以理解。

4.2. 对象的传递以接口为中心

阅读 Media Foundation 的 API 时会发现,大部分返回值和 out 参数都是 COM 接口。

  • IMFSourceReader
  • IMFMediaType
  • IMFTransform
  • IMFActivate
  • IMFSample
  • IMFMediaBuffer

特别之处在于,不仅数据本体,连类型信息和配置对象也都用接口来表示

例如:

  • IMFTransform 是表示 MFT 的接口
  • IMFAttributes 是一个 key/value 存储
  • IMFMediaType 是继承自 IMFAttributes 的「媒体格式说明」

像 media type 这类「感觉上属于配置数据」的东西,也是用 COM 接口来承载的。到这里,IUnknownQueryInterfaceAddRef / ReleaseHRESULT 这些上下文自然就出现了。

IUnknownIMFAttributesIMFMediaTypeIMFActivateIMFSourceReaderIMFTransform

到这一步,就能看出「Media Foundation 是媒体 API,但边界的表达方式相当 COM 化」。

4.3. Activation Object 的出现

Media Foundation 的 COM 味特别浓的地方,就是 activation object。

IMFActivate 是用于稍后创建本体的辅助对象。可以把它类比为 COM 中的 class factory,这样理解起来会更容易。

在这种场景中,枚举 API 的返回值有时不是「可以直接使用的本体」,而是一组 IMFActivate* 数组。 然后,只对需要的那个调用 ActivateObject 来实例化。

IMFTransform / Sink 等IMFActivate枚举 API应用程序IMFTransform / Sink 等IMFActivate枚举 API应用程序调用枚举IMFActivate* 数组确认属性ActivateObject(...)实体 COM 对象

这种形式与 Media Foundation 事后寻找并组合可替换部件的设计 很契合。

另外,activation object 本身也可以持有 attributes,所以经常会出现「先查看候选的属性」「按需设置」「稍后再实例化」这样的流程。这一点也相当 COM 化。

4.4. 配置与类型信息以 IMFAttributes 和 GUID 为中心

使用 Media Foundation 时,会有一个瞬间让人觉得配置突然变得全是 GUID。其核心就是 IMFAttributes,一个以 GUID 为键的 key/value 存储。它在整个 Media Foundation 中被大量使用。

其中特别重要的是 IMFMediaType,它继承自 IMFAttributes,把媒体格式信息作为属性来持有。

例如以下信息:

  • major type(音频还是视频)
  • subtype(H.264、AAC、RGB32、PCM 等)
  • 帧尺寸
  • 帧率
  • 采样率
  • 通道数
IMFMediaTypeMF_MT_MAJOR_TYPEMF_MT_SUBTYPE尺寸 / FPS / 采样率等

这里很容易被感觉成「GUID 的森林」,但实际做的事情相当直白:

  • 用属性存储来保存配置
  • media type 也用属性存储来表示
  • source / transform / sink 之间通过查看这些属性来协调格式

配置和类型信息的表达方式使用了 COM 式的接口与 GUID,仅此而已。

4.5. 异步、回调、线程的处理方式也很 COM

在 Media Foundation 的实务中容易被忽略的是异步处理和线程模型。

例如,Source Reader 默认是同步模式。在同步模式下,ReadSample 会阻塞。 根据文件、网络或设备的状态,这个等待有时会变成明显可见的时长。

如果想切换到异步模式,需要在创建 Source Reader 时传入 callback。 流程是:准备一个实现了 IMFSourceReaderCallback 的对象,把它设置到 MF_SOURCE_READER_ASYNC_CALLBACK 属性中,然后再创建。

另外一个比较重要的点是 apartment。 Media Foundation 的异步处理使用 work queue,work queue 所在的线程是 MTA。 因此,把应用侧也统一到 MTA 会让实现更简单。

IMFSourceReaderCallbackMF work queue (MTA)Source Reader应用线程IMFSourceReaderCallbackMF work queue (MTA)Source Reader应用线程ReadSample(...)立即返回在内部处理OnReadSample(...)

在 callback 相关的地方,需要注意以下几点:

  • 不要在 callback 中直接操作 UI 线程上的 STA 对象
  • callback 的实现必须是 线程安全 的
  • 如果需要更新 UI,应该只把结果传回 UI 线程
  • 提前固定「Media Foundation 的 callback 会从哪个线程过来」这一前提

Media Foundation 并不会自动帮你妥善处理 STA 对象的问题。 因此,把使用 Media Foundation 的 worker 统一到 MTA,并与 UI 之间明确搭建桥梁,会更容易理清结构。

4.6. 但 Media Foundation 并不等于 COM

读到这里,很容易产生「说到底 Media Foundation 就是 COM 本身」的想法。 但这一点略有偏差。

Media Foundation 中存在一些无法单纯用 COM 的一般性理论来解释的、平台特有的概念。

  • MFStartup / MFShutdown
  • Media Session
  • topology
  • topology loader
  • presentation clock
  • Source Reader / Sink Writer

这些都属于 如何驱动媒体管道 这一 Media Foundation 自身的职责范围。

例如在 Media Session 中,应用程序传入 partial topology 后,topology loader 会补充所需的 transform,将其解析为 full topology。 这并不是 COM 的通用话题,而是 Media Foundation 作为媒体处理平台所具备的功能。

Partial TopologySource -> OutputTopology LoaderFull TopologySource -> Decoder MFT -> Output

Media Foundation 是 一方面用 COM 表达部件之间的契约,另一方面作为媒体处理平台运行 的产物。用这种两层结构来看待它,就不容易迷失方向。

5. 大致的使用区分

在决定最初的入口时,下面这张图往往就足够了。

想读取帧 / 采样想写入文件需要播放控制或 A/V 同步想插入自定义转换器想做的事最先需要什么?Source ReaderSink WriterMedia SessionMFT

5.1. 先从 Source Reader 入手的情况

如果想从文件或设备中取出数据,Source Reader 作为入口相当容易使用。

比如下面这些场景比较适合:

  • 想从视频文件中取出帧
  • 想解码音频文件并取出采样
  • 想从摄像头取出帧
  • 想把 Media Foundation 的 source 接入自己的处理管道

Source Reader 会按需加载 decoder,并把数据传递给应用程序。 但另一方面,它不会管理 presentation clock、A/V 同步,更不负责画面渲染本身。

理解为 「不是用来播放,而是用来取数据」的入口,会更容易把握。

5.2. 要写入文件就用 Sink Writer

Sink Writer 是想把音频或视频写入文件时的入口。

典型用途包括:

  • 想把生成的帧保存为视频文件
  • 想编码音频采样并写出
  • 想把读取的数据转换成其他格式后保存

Sink Writer 会按需查找并加载 encoder,并管理向 media sink 的数据流转。 它常与 Source Reader 组合使用,但两者是各自独立的部件,并不一定要成对使用。

5.3. 需要播放和同步就用 Media Session

如果不只是「想从文件里取数据」,而是 想认真地播放,那么以 Media Session 为核心来考虑会更顺理成章。

在有以下需求时,就是 Media Session 登场的时候:

  • 想处理播放 / 停止 / 跳转
  • 想把音视频同步交给平台处理
  • 想连质量控制和格式变化都纳入管道来处理
  • 想用 topology 来组织 source / transform / sink 的流转

进入这一层后,会比 Source Reader / Sink Writer 更接近「Media Foundation 本体」。 相应地,topology、session event 等 Media Foundation 特有的概念也会增多。

5.4. 需要插入自定义部件就用 MFT

MFT 是 Media Foundation 中 transform 的通用模型。

以下场景适合使用 MFT:

  • 想自己实现解码器或编码器
  • 想把视频处理或音频处理部件插入管道
  • 想枚举 codec 或转换器,并自行挑选
  • 想比默认的自动解析进行更深入的控制

在 MFT 的世界里,IMFTransformIMFActivate、media type negotiation、样本 / 缓冲区管理等 COM 式契约会非常突出。 因此,与一开始就直接进入 MFT 相比,先确认 Source Reader / Sink Writer / Media Session 中哪一个才是真正需要的,会更容易理解。

6. 实务检查清单

最后,把实务中最先应该确认的要点整理成一张表。

项目 需要确认的内容 忽略后容易出现的问题
初始化职责 决定在哪里调用 CoInitializeExMFStartup,以及由谁负责终止处理 初始化遗漏、终止顺序混乱
apartment 提前决定使用 MF 的线程是 STA 还是 MTA callback 相关的混乱、与 UI 冲突
Source Reader 的模式 在创建时决定是同步还是异步 ReadSample 出现意外阻塞,且之后无法切换
media type negotiation 枚举输出格式,并明确指定实际使用的格式 出现 MF_E_INVALIDMEDIATYPE,或得到与预期不同的格式
对象生命周期 明确 ReleaseUnlockShutdownObject 的职责 内存泄漏、缓冲区滞留、终止时状态不一致
activation object 区分枚举结果究竟是本体还是 IMFActivate 误以为可以 QueryInterface 而失败
topology 掌握当前处理的是 partial topology 还是 full topology 以为「应该会自动连接」而卡住
错误检查 每次都检查 HRESULT、stream flags、event 忽略部分失败而未察觉
UI 联动 不在 callback 中直接操作 UI,只把结果传回 UI 线程 挂起、竞争、难以排查的故障

其中优先级特别高的是以下 3 点:

  1. 不要选错最初的入口 API
    • 先分清 Source Reader / Sink Writer / Media Session 中哪一个才是真正需要的
  2. 提前确定 apartment
    • 如果要把 STA 的 UI 与 Media Foundation 的 work queue 混用,先确定好搭桥的方式
  3. 不要草率处理 media type negotiation
    • 用「大概是这个格式吧」推进,之后会变得相当难以理清

7. 代码摘录

这里不放完整示例,只放 能看出哪里会显出 COM 一面的摘录

7.1. 初始化

template <class T>
void SafeRelease(T** pp)
{
    if (pp != nullptr && *pp != nullptr)
    {
        (*pp)->Release();
        *pp = nullptr;
    }
}

HRESULT InitializeMediaFoundationForCurrentThread()
{
    HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
    if (FAILED(hr))
    {
        return hr;
    }

    hr = MFStartup(MF_VERSION);
    if (FAILED(hr))
    {
        CoUninitialize();
        return hr;
    }

    return S_OK;
}

void UninitializeMediaFoundationForCurrentThread()
{
    MFShutdown();
    CoUninitialize();
}

这里,CoInitializeExMFStartup 并列出现。 这正是使用 Media Foundation 时,COM 气息突然变浓的最初位置。

在实际实现中,有时其他层已经负责了 COM 初始化。即便如此,提前固定由谁负责这项职责会更安全。

7.2. 以同步模式创建 Source Reader

HRESULT ReadOneVideoSample(PCWSTR path)
{
    IMFSourceReader* pReader = nullptr;
    IMFMediaType* pType = nullptr;
    IMFSample* pSample = nullptr;

    HRESULT hr = MFCreateSourceReaderFromURL(path, nullptr, &pReader);
    if (FAILED(hr)) goto done;

    hr = MFCreateMediaType(&pType);
    if (FAILED(hr)) goto done;

    hr = pType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
    if (FAILED(hr)) goto done;

    hr = pType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
    if (FAILED(hr)) goto done;

    hr = pReader->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        nullptr,
        pType);
    if (FAILED(hr)) goto done;

    DWORD streamFlags = 0;
    LONGLONG timestamp = 0;

    hr = pReader->ReadSample(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        0,
        nullptr,
        &streamFlags,
        &timestamp,
        &pSample);
    if (FAILED(hr)) goto done;

    // 从 pSample 中取出 IMFMediaBuffer 并处理

done:
    SafeRelease(&pSample);
    SafeRelease(&pType);
    SafeRelease(&pReader);
    return hr;
}

从这里可以看出以下几点:

  • reader 和 media type 都是 COM 接口
  • 配置是基于 GUID 的
  • 返回值是 HRESULT
  • 在同步模式下,ReadSample 会阻塞

哪怕「只想读取 1 帧」,在 Media Foundation 的边界处也会呈现出相当 COM 化的一面。

7.3. 以异步模式创建 Source Reader

HRESULT CreateSourceReaderAsync(
    PCWSTR path,
    IMFSourceReaderCallback* pCallback,
    IMFSourceReader** ppReader)
{
    IMFAttributes* pAttributes = nullptr;

    HRESULT hr = MFCreateAttributes(&pAttributes, 1);
    if (FAILED(hr))
    {
        return hr;
    }

    hr = pAttributes->SetUnknown(MF_SOURCE_READER_ASYNC_CALLBACK, pCallback);
    if (SUCCEEDED(hr))
    {
        hr = MFCreateSourceReaderFromURL(path, pAttributes, ppReader);
    }

    SafeRelease(&pAttributes);
    return hr;
}

这里为了启用异步模式,先把 callback 放入属性中,然后再创建 reader。

也就是说:

  • callback 本身就是 COM 接口
  • 异步设置是通过 IMFAttributes 完成的
  • 模式在创建时就已确定

在实务中,重要的是让 IMFSourceReaderCallback 的实现保持 线程安全,并且不要直接携带 UI 对象。

7.4. 用 MFTEnumEx 枚举并实例化 MFT

HRESULT FindH264Decoder(IMFTransform** ppTransform)
{
    *ppTransform = nullptr;

    IMFActivate** ppActivate = nullptr;
    UINT32 count = 0;

    MFT_REGISTER_TYPE_INFO inputType = {};
    inputType.guidMajorType = MFMediaType_Video;
    inputType.guidSubtype = MFVideoFormat_H264;

    HRESULT hr = MFTEnumEx(
        MFT_CATEGORY_VIDEO_DECODER,
        MFT_ENUM_FLAG_SYNCMFT | MFT_ENUM_FLAG_LOCALMFT,
        &inputType,
        nullptr,
        &ppActivate,
        &count);
    if (FAILED(hr))
    {
        return hr;
    }

    if (count == 0)
    {
        CoTaskMemFree(ppActivate);
        return MF_E_TOPO_CODEC_NOT_FOUND;
    }

    hr = ppActivate[0]->ActivateObject(
        __uuidof(IMFTransform),
        reinterpret_cast<void**>(ppTransform));

    for (UINT32 i = 0; i < count; ++i)
    {
        ppActivate[i]->Release();
    }
    CoTaskMemFree(ppActivate);

    return hr;
}

这里,枚举结果返回的一开始并不是 IMFTransform*,而是 IMFActivate**。 然后调用 ActivateObject,才终于取得实体的 IMFTransform

这个流程,相当好地体现了 Media Foundation「突然显出 COM 一面」的感觉。

8. 总结

使用 Media Foundation 时突然涌现大量 COM 相关话题,并不是偶然。

  • Media Foundation 是一个媒体处理平台
  • 其 source / transform / sink / activation / callback 等边界都用 COM 接口来表示
  • 因此 IUnknownHRESULT、GUID、apartment、callback 这些话题会自然出现
  • 但 Media Foundation 的本体是拥有 Media Session 和 topology 的媒体管道,并不只是 COM 的翻版

在实务中,按下面的顺序思考会相当有条理:

  1. 首先分清究竟需要 Source Reader / Sink Writer / Media Session / MFT 中的哪一个
  2. 提前确定 apartment 和 callback 的方针
  3. 认真处理 media type negotiation 和对象生命周期

不需要一开始就理解全部内容。 只要先把握住 「Media Foundation 是媒体处理平台,COM 深深嵌入在它的边界处」 这一点,阅读文档和代码都会容易得多。

9. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

Media Foundation 是什么?和 COM 有什么区别?
Media Foundation 是 Windows 上用于处理视频和音频的媒体处理平台,并不是说整个 API 都是纯粹的 COM。不过,source / transform / sink / activation / attributes / callback 等部件之间的边界都是通过 COM 接口来表示的,所以使用过程中自然会涉及 IUnknown、HRESULT、GUID、apartment 这些话题。准确的理解是:「Media Foundation 是一个媒体处理平台,其边界处深深地嵌入了 COM」。
为什么 MFStartup 和 CoInitializeEx 两个都需要调用?
因为它们的职责不同。CoInitializeEx 用于初始化 COM 库,MFStartup 用于初始化 Media Foundation 平台。仅完成 COM 初始化是不够的,还需要单独初始化 Media Foundation。在实务中,提前决定好由哪个线程使用 Media Foundation、该线程应设为 STA 还是 MTA、由谁负责 MFStartup / MFShutdown 与 CoInitializeEx / CoUninitialize 的调用职责,之后处理回调和 UI 联动时会更轻松。
Source Reader、Sink Writer、Media Session、MFT 该如何区分使用?
如果想从文件或摄像头中取出帧或采样数据,入口是 Source Reader;如果想把生成的音频、视频写入文件,入口是 Sink Writer。如果想把播放、停止、跳转、A/V 同步以及质量控制都交给平台处理,就要以 Media Session 为核心来考虑。如果想把自定义的解码器或转换器接入管道,就需要用到 MFT,但由于这里 COM 式的契约会更突出,建议先确认前面三者中哪一个才是真正需要的。
使用 Media Foundation 的异步回调时需要注意什么?
Media Foundation 的异步处理使用 work queue,该线程是 MTA,因此把应用侧也统一到 MTA 会让实现更简单。IMFSourceReaderCallback 的实现必须是线程安全的,重要的是不要在回调中直接操作 UI 线程上的 STA 对象。如果需要更新 UI,应该只把结果传回 UI 线程。另外还要注意,Source Reader 的同步 / 异步模式在创建时就已确定,之后无法切换。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表