你有没有遇到过这种情况Creo 二次开发的项目刚拿到手需求方就提了一堆“要弹出窗口、要BOM表、要参数批量修改、要带颜色标识”的要求。你心里很清楚老老实实用 C Toolkit 加 MFC 去画界面光是控件布局和消息映射就足够让人加班到怀疑人生。但如果直接用 C# 写 WinForms界面效率确实高可惜 Creo 官方 API 是 C/C 的C# 根本调不了 ProParameterVisit。于是问题就变成了C 和 C# 怎么才能在一个 Creo 二次开发项目里好好配合这篇文章就是我自己在真实项目里反复折腾后总结出来的混编实战经验从架构选型、工程配置、代码实现到那些一踩一个准的坑都会讲清楚。适合已经对 Creo Toolkit 有基本概念、但想摆脱“纯 C”或“纯 C#”限制的工程师。下面直接进正题。1. 为什么非要把 C 和 C# 搭在一起Creo 二次开发的语言选型真相先说一个容易忽略的事实Creo 的二次开发从来都不止一条路。官方正经支持的开发接口包括 C/C 的 Toolkit、Java 的 J-Link、以及基于 COM/OLE 自动化的一些能力。很多刚入门的工程师会把“做 Creo 开发”简单等同于“用 C 写 Toolkit”这其实是个误区。我见过不少项目实际需求只是批量改参数、生成配置文件、导 BOM这种场景用 J-Link 或者 COM 就能做根本不需要碰 C。但如果需求涉及深度建模操作、特征遍历、几何查询或者要嵌入到 Creo 进程内实时响应那么 Toolkit 依然是绕不开的主力。问题在于Toolkit 的开发效率和界面表达能力实在一言难尽——MFC 写企业级界面维护成本高得吓人而 C# 有现代 UI 框架、丰富第三方控件库、成熟的异步编程模型做管理型界面几乎是碾压级的优势。于是在真正的产品级项目中一个非常自然的架构就出现了C 负责和 Creo 深度交互C# 负责外部业务系统和界面两者通过进程间通信协作。用一句话概括就是C 干活C# 露脸。这里有三类方案我平时会放在一起对比大家可以根据项目体量来选方案开发语言运行位置稳定性开发效率适用场景同步 Toolkit 插件CCreo 进程内较差崩溃会拖垮 Creo低深度建模、特征级操作异步 Toolkit 程序C独立进程较好崩溃隔离低需要外部长期驱动的批处理系统J-LinkJava独立进程/RPC较好中企业级集成、跨平台需求COM/OLE 自动化C#/VB.NET独立进程中等高简单参数读取、自动出图辅助C Toolkit C# 外部程序C C#混合架构高C# 崩溃不影响 Creo较高复杂业务系统本文主题上面前四种都是单语言方案各有明显短板。而混合架构的思路是取长补短C Toolkit 做成同步 DLL 插件在 Creo 进程内注册负责所有 ProToolkit API 调用和菜单注册C# 写一个独立进程的 WinForms/WPF 程序负责用户界面和业务逻辑二者之间用命名管道Named Pipes或者其他 IPC 机制通信。这样一旦 C# 界面崩溃Creo 主进程完全不受影响。这是我反复验证后认为最适合中型以上项目的组合方式。不过这个架构没有说的那么简单下面几章我会按“工程搭建 → C 端实现 → C# 端实现 → 踩坑清单 → 完整示例”的顺序把每个环节的细节和坑都摊开讲。2. 开局先避雷版本配对、位数匹配与 protk.dat 注册混合编程最容易在项目还没开始写业务代码时就给人一记闷棍DLL 编译出来了放到 Creo 里加载却毫无反应或者直接报错“无法找到指定模块”。这大概率不是代码问题而是工程搭建环节出了错。这个阶段有三个高优先级事项任何一个不对后面的工作全白搭。第一Creo 主版本与 Visual Studio 版本必须匹配。Creo Toolkit 的官方文档里对每个版本都注明了推荐编译器和对应字符集设置。比如 Creo 8.0 时代通常使用 VS2017/VS2019Creo 10.0 以后 VS2022 才算稳妥。如果你用 Toolkit 头文件里没适配过的新版编译器去编很容易碰到关于_MSC_VER的预处理器报错、标准库行为不一致的诡异问题。我的建议是安装 Creo 时顺便看安装目录下Common Files\toolkit\README或示例工程里的makefile里面会写明官方指定的编译环境。这一步不要图新编译器不是越新越好。第二位数匹配是硬性条件。现在主流的桌面版 Creo 基本都是 64 位进程所以你的 C Toolkit DLL 必须编译成 x64 版本。这一点看起来简单但实际操作里经常有人建项目时随手选了 Win32编译一行报错都没有放到 Creo 里加载就翻车。C# 端也要小心如果引用了任何本机 DLL必须保证 C# 程序的运行平台是 x64或者至少是允许 x64 执行的 AnyCPU。我在一个项目里就见过 C# 编译成 x86管道连上了但 C 端回调地址对不上的情况查了半天根因就是位数。VS 里解决方案平台的设置建议统一改成 x64Debug 和 Release 都改。第三protk.dat 注册文件是 Timeline 前的第一道门槛。下面是实际项目里一直在用的一个模板name creo-mixed-demo startup dll exec_file D:\Projects\creo_mixed\bin\x64\creo_mixed.dll text_dir D:\Projects\creo_mixed\text allow_stop FALSE delay_start FALSE revision 2600 end这里面最坑的是revision这个字段它对应 Creo 内部版本号不同的 Creo 大版本有不同取值而且不像 4.0、5.0 这种对外命名那么好记。如果你照抄网上的模板很容易因为版本号不符导致 DLL 加载失败或注册成功但命令不可用。最可靠的办法是打开 Creo 安装目录下 Toolkit 的自带示例protk.dat直接从里面复制对应版本的值或者安装后查看Creo\Common Files\creo_standards\protk.dat。另外exec_file路径尽量不要出现中文和空格别问为什么问就是你在用 Windows Creo。protk.dat写好后通过环境变量TOOLKIT_REGISTRY_FILE指向它或者在 Creo 启动时用“工具 → 辅助应用程序 → 注册”手动加载。调试阶段建议用后者因为可以随时点“启动/停止”来观察插件加载行为出错日志也更容易定位。3. C 插件端代码结构、线程模型和跨进程数据契约C Toolkit 插件在混合架构里的地位是“一切 Creo 操作的唯一入口”。这个模块设计得好不好直接决定了系统稳不稳定。我见过不少混编项目最后演变成 C# 直接调一堆 DllImport 导出的 C 函数结果一个内存错误直接让 Creo 进程崩溃这就是没有做好职责边界。先看入口结构。一个同步 Toolkit DLL 必须具备两个全局函数user_initialize和user_terminate。前者在插件加载时被 Creo 调用负责菜单注册、环境初始化后者在插件卸载时调用负责回收资源。下面是核心骨架// creo_mixed.cpp #include ProToolkit.h #include ProMenuBar.h #include ProParameter.h #include ProMdl.h #include ProString.h #include windows.h #include string #include vector #include deque #include mutex #include thread static std::thread g_pipe_thread; static bool g_running false; // 管道接收线程的入口 static void PipeServerThreadProc(); // 菜单命令回调启动外部 C# 程序 static void StartCSharpUi(uiCmdCmdId cmd_id, uiCmdValue* p_value, void* p_user_data) { // 用 Win32 启动外部 exe不阻塞 Creo ShellExecuteW(NULL, Lopen, LD:\\Projects\\creo_mixed\\bin\\x64\\MixedUi.exe, L, NULL, SW_SHOW); } extern C int user_initialize() { ProError status; uiCmdCmdId cmd_id; status ProCmdActionAdd(StartCSharpUi, (uiCmdCmdActFn)StartCSharpUi, uiProe2nd, NULL, PRO_CMD_KEEP_OLD_MENU, PRO_CMD_NO_UI, cmd_id); if (status ! PRO_TK_NO_ERROR) return 1; status ProMenubarmenuPushbuttonAdd( user_menu, MixedToolkit, C#混编示例, C#混编示例, NULL, PRO_B_TRUE, cmd_id, PRO_MENU_NOTUSED); if (status ! PRO_TK_NO_ERROR) return 1; // 启动管道服务器线程 g_running true; g_pipe_thread std::thread(PipeServerThreadProc); return 0; } extern C void user_terminate() { g_running false; if (g_pipe_thread.joinable()) g_pipe_thread.join(); }这里有几个设计细节值得单独解释一下。关于 C# 程序的启动方式。很多人的第一反应是system()或者CreateProcess直接启动 exe然后 C 阻塞等待进程结束。这是错的。Creo 的 UI 线程非常敏感一旦有阻塞或长时间操作很容易出现“Creo 未响应”或者后续菜单命令失效。我的做法是在同步 Toolkit 内部收到菜单点击后使用 ShellExecute 异步启动外部程序立刻返回让 Creo 界面保持流畅。关于管道服务器。管道服务器线程只做一件事监听客户端的连接请求读取消息把任务放进一个线程安全的队列然后立刻回到监听状态。为什么不能在这个线程里直接调用 ProParameterVisit 这类 API因为同步 Toolkit 的绝大多数交互接口都要求运行在 Creo 主线程官方文档虽然没把所有例外列出来但实践中我只要一在后台线程直接调模型操作接口轻则数据错乱重则整个 Creo 卡死。这个问题在后线的避坑清单里还会再讲。关于命令队列与主线程的衔接。一个实际可用的做法是在同步 Toolkit 的某个回调事件里定期检查队列。比如利用 Creo 的空闲回调或者更简单的方案是把“处理队列”做成一个自定义菜单命令由 C# 端在发消息后通过发送快捷键或命令的方式“踢一脚”。如果嫌这个方案绕也可以退一步管道线程只处理无风险的信息接口比如ProMdlCurrentGet、ProMdlTypeGet这类只读且不涉及遍历的调用涉及到模型参数遍历、特征操作的内容一律回到主线程。这里给出一个安全的队列实现示例struct ToolkitCommand { std::string request_id; std::string action; // 例如 GET_PARAMS std::vectorstd::string args; }; std::mutex g_queue_mutex; std::dequeToolkitCommand g_cmd_queue; void EnqueueCommand(const ToolkitCommand cmd) { std::lock_guardstd::mutex lock(g_queue_mutex); g_cmd_queue.push_back(cmd); } bool TryDequeueCommand(ToolkitCommand cmd) { std::lock_guardstd::mutex lock(g_queue_mutex); if (g_cmd_queue.empty()) return false; cmd g_cmd_queue.front(); g_cmd_queue.pop_front(); return true; }关于跨进程数据契约。C 端和 C# 端通过管道传输的是字节流所以必须提前约定好序列化格式。我平时用的是最简单的 UTF-8 JSON 行协议即每条消息以换行符结尾字段统一用 JSON。原因很简单好调试、好扩展C 侧可以用rapidjson或nlohmann/jsonC# 侧直接System.Text.Json反序列化中文不会乱码。如果你用固定二进制结构体C 端的#pragma pack和 C# 端的StructLayout一旦对不上数据错乱是最难排查的一类问题。初次做混编项目强烈建议先上 JSON 行协议。4. C# 客户端为什么不建议 P/Invoke以及管道通信怎么写很多混合编程的初学者拿到这个方案后第一反应是既然 C 编译出来的 Toolkit DLL 是标准 DLL那我在 C# 里直接DllImport不就行了理论上确实可行同步 Toolkit DLL 里的所有导出函数都可以被 C# 以 P/Invoke 方式调用但实际操作下来这条捷径会带来一串让人崩溃的问题。首先进程模型完全不对。同步 Toolkit DLL 的运行前提是它被加载到 Creo 进程内由 Creo 调用它的user_initialize。如果你在 C# 进程里用DllImport去加载同一个 DLL相当于要把 Creo 的整个 Toolkit 运行环境也在 C# 进程里搭一遍这基本不现实而且在同一个进程里初始化两套 Toolkit 会造成冲突。其次崩溃隔离性为零。C# 通过 P/Invoke 进入 C 代码一旦 C 指针越界异常会直接当前整个进程你连救的机会都没有。所以我的结论很明确在 Creo 二次开发这个语境下跨语言的正确姿势不是 DLL 层面互调而是进程层面通信。C# 端把自己定位成“客户端”通过命名管道连接 C 插件发送请求等待响应。下面是一个简洁的 C# 命名管道客户端封装放在 WinForms 项目里就可以直接使用using System; using System.IO; using System.IO.Pipes; using System.Text; public static class ToolkitPipeClient { private const string PipeName CreoToolkitBridge; public static string Send(string message, int timeoutMilliseconds 5000) { using (var pipe new NamedPipeClientStream(., PipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) { pipe.Connect(timeoutMilliseconds); var writeBuffer Encoding.UTF8.GetBytes(message \n); pipe.Write(writeBuffer, 0, writeBuffer.Length); pipe.Flush(); using (var reader new StreamReader(pipe, Encoding.UTF8)) { return reader.ReadLine(); } } } }在 WinForms 的按钮事件里需要特别注意别在 UI 线程直接调用上面这个方法。原因很简单如果 C 端处理比较慢UI 线程被卡住整个窗体就会进入“白屏无响应”状态。正确做法是启用异步private async void btnGetParams_Click(object sender, EventArgs e) { btnGetParams.Enabled false; try { var result await Task.Run(() ToolkitPipeClient.Send(GET_PARAMS)); // 解析 JSON 并绑定 DataGridView var doc System.Text.Json.JsonDocument.Parse(result); var root doc.RootElement; var items root.GetProperty(params).EnumerateArray(); BindingListParamRow rows new BindingListParamRow(); foreach (var item in items) { rows.Add(new ParamRow { Name item.GetProperty(name).GetString(), Value item.GetProperty(value).GetDouble() }); } dataGridView1.DataSource rows; } catch (Exception ex) { MessageBox.Show(调用失败 ex.Message); } finally { btnGetParams.Enabled true; } }这个模式下C# 端不接触任何 Creo API也不知道 ProToolkit 内部长什么样。它只知道“发一个字符串请求拿一个字符串响应”这层抽象能够极大降低后续维护成本。万一 C 端某个函数导致管道传输数据格式变化只需要双方修改对应的序列化/反序列化逻辑不会牵动 UI 层。5. 避坑清单我踩过的 8 个混合编程的坑混编项目里真正耗费时间的往往不是功能设计而是一堆莫名其妙的低级错误。这里把我自己以及同事踩过的坑整理成清单每一条都是真实案例排序分先后越靠前越高频。坑 1DLL 位数与 Creo 进程位数不匹配。现象Creo 加载 DLL 无反应或者日志里出现“Module not found”“Load failed”等不明错误。排查方式用dumpbin /headers查看 DLL 机器类型确认是 x64 还是 x86。这是个一次性的工程配置问题但发生概率极高因为我见过太多人直接从工具箱里复制一个旧项目改了名字就开编。坑 2protk.dat 文件里面 revision 号不对。现象注册时报错或者在“辅助应用程序”对话框里显示启动失败。有的版本甚至不报错只是菜单项不出现。解决办法前面已经提过从 Toolkit 自带的样例文件里复制不要在网上找模板硬套。坑 3跨模块内存释放导致堆损坏。如果 C DLL 内部用malloc或new分配了缓冲区然后把指针传给 C# 端使用C# 端千万不能用Marshal.FreeCoTaskMem去释放。因为这是两个完全不同的堆。对于管道通信方案这个坑天然被规避了——C 端把字符串编码成字节流后通过管道发送接收端自行管理内存不涉及跨模块显式释放。但如果有人坚持用 P/Invoke 方案请务必约定“谁分配谁释放”并且使用同一个 CRT/堆。坑 4C# 字符串编码与 C 端不一致导致的中文乱码。Creo 内部老版本 API 大量使用char[]数组编码依赖本地代码页中文环境下通常是 GBK而 C# 字符串是 UTF-16。如果在跨语言边界直接传原始字节中文参数名变成“锟斤拷”一点不奇怪。我的方案是两端统一用 UTF-8C 端负责把ProName转成 UTF-8 字符串C# 端直接按 UTF-8 解码彻底避免代码页问题。坑 5在管道后台线程里直接调用 ProToolkit 导致 Creo 卡死。我在项目初期为了图省事直接在管道接收线程里调用了ProParameterVisit运行到大约第 200 个参数时 Creo 直接无响应。后来把代码改回队列 主线程处理模式问题才消失。如果你的项目碰到 Creo 卡死或崩溃先想想自己是不是在非主线程干了一些不该干的活。坑 6ShellExecute 启动 C# 程序时路径写死效率低且不可移植。很多人会在 C 里写死D:\Projects\...\MixedUi.exe。如果项目换了部署环境忘记改路径菜单点击后什么反应都没有。更好的做法是把 C# exe 的路径写到protk.dat的环境变量或一个外置配置文件里DLL 启动时动态读取。坑 7频繁发送小请求导致管道通信成为瓶颈。每次 C# 发一个GET_PARAMSC 端都要走一次完整的入队、主线程处理、结果回传流程整体延迟可能高达几十毫秒。如果你要批量读取成千上万个参数逐条发请求是灾难。正确做法是扩展请求协议比如定义一个“批量请求”命令让 C 端一次遍历完整个参数表返回一个大的 JSON 数组。坑 8工具退出时 C# 端连接没断开导致 C 端管道服务器卡在 ConnectNamedPipe。C# 程序崩溃、用户直接杀进程C 端的管道连接不会自动及时得到通知可能阻塞住监听线程。解决方式是在ConnectNamedPipe调用前设置超时或定期检查g_running确保 Creo 退出时后台线程能正常结束。这一步别看小处理不好Creo 正常关闭时可能会卡住几十秒。6. 完整链路示例C# 窗体读取 Creo 当前模型参数列表前面讲了很多原则这一章给一个有代表性的完整封闭链路C# 窗体上点按钮C Toolkit 遍历当前模型参数把参数名和值返回 C#并在 DataGridView 中显示。这个例子几乎是所有混编项目的“Hello World”代码骨架可以直接复用。先看 C 端负责执行参数遍历的核心函数。这个函数必须运行在 Creo 主线程通常由菜单回调触发。为便于讲解我把获取参数逻辑封装成一个独立函数#include ProParamVal.h #include ProParameter.h #include ProMdl.h struct ParamEntry { std::string name; double value; }; static ProError VisitParamCallback(ProParameter* parameter, ProError status, ProAppData app_data) { if (status ! PRO_TK_NO_ERROR) return PRO_TK_CONTINUE; auto* params static_caststd::vectorParamEntry*(app_data); ProName name; if (ProParameterNameGet(parameter, name) PRO_TK_NO_ERROR) { ProParamvalue value; if (ProParameterValueGet(parameter, value) PRO_TK_NO_ERROR) { std::string utf8_name ConvertProNameToUtf8(name); if (value.type PRO_PARAM_DOUBLE || value.type PRO_PARAM_INTEGER) { double numeric (value.type PRO_PARAM_DOUBLE) ? value.value.d_val : static_castdouble(value.value.i_val); params-push_back({utf8_name, numeric}); } } } return PRO_TK_CONTINUE; } std::string GetCurrentModelParamsAsJson() { ProMdl mdl; std::vectorParamEntry entries; if (ProMdlCurrentGet(mdl) PRO_TK_NO_ERROR) { ProParameterVisit(mdl, (ProParameterFilterActions)NULL, VisitParamCallback, (ProAppData)entries); } // 将 entries 转成 JSON 字符串例如 // {params:[{name:d1,value:12.5}, ...]} return BuildJsonFromEntries(entries); }这段代码里的ConvertProNameToUtf8需要自己实现逻辑不复杂先拿到ProName其实就是char[PRO_NAME_SIZE]把它从本地代码页转成宽字符串再转成 UTF-8。这里我强烈建议所有跨进程字符串一律标准化为 UTF-8中文参数名才不会再出问题。接着看 C 端管道服务器收到请求后如何分发。简化版的分发逻辑如下static void HandleRequest(const std::string request_body, std::string response_body) { // 最简协议按行解析取第一个单词作为 action if (request_body GET_PARAMS) { // 注意此处应在主线程队列中执行这里只展示分发逻辑 response_body GetCurrentModelParamsAsJson(); } else { response_body {\error\:\unknown action\}; } } static void PipeServerThreadProc() { while (g_running) { HANDLE pipe CreateNamedPipeW( L\\\\.\\pipe\\CreoToolkitBridge, PIPE_ACCESS_DUPLEX, PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, PIPE_UNLIMITED_INSTANCES, 64 * 1024, 64 * 1024, 0, NULL); if (pipe INVALID_HANDLE_VALUE) break; if (ConnectNamedPipe(pipe, NULL) || GetLastError() ERROR_PIPE_CONNECTED) { char buffer[4096] {0}; DWORD bytes_read 0; if (ReadFile(pipe, buffer, sizeof(buffer) - 1, bytes_read, NULL)) { std::string request_body(buffer, bytes_read); std::string response_body; HandleRequest(request_body, response_body); DWORD bytes_written 0; WriteFile(pipe, response_body.data(), static_castDWORD(response_body.size()), bytes_written, NULL); } } CloseHandle(pipe); } }注意我把“在后台线程直接调用 GetCurrentModelParamsAsJson”这种方式标成了风险点。正确的工程化做法是在线程里入队并把任务内容标记为“GET_PARAMS”然后由主线程管道的消息里取出并执行。为了不把示例代码膨胀得过分复杂此处只展示分发逻辑实际项目务必加上队列机制。最后是 C# 端完整的窗体代码省略了设计器文件核心逻辑都在public partial class MixedToolkitForm : Form { private readonly BindingListParamRow _rows new BindingListParamRow(); public MixedToolkitForm() { InitializeComponent(); dataGridView1.DataSource _rows; dataGridView1.AutoSizeColumnsMode DataGridViewAutoSizeColumnsMode.Fill; } private async void btnRefresh_Click(object sender, EventArgs e) { btnRefresh.Enabled false; _rows.Clear(); try { string json await Task.Run(() ToolkitPipeClient.Send(GET_PARAMS)); var doc System.Text.Json.JsonDocument.Parse(json); foreach (var item in doc.RootElement.GetProperty(params).EnumerateArray()) { _rows.Add(new ParamRow { Name item.GetProperty(name).GetString(), Value item.GetProperty(value).GetDouble() }); } } catch (Exception ex) { MessageBox.Show(读取失败: ex.Message, 提示, MessageBoxButtons.OK, MessageBoxIcon.Warning); } finally { btnRefresh.Enabled true; } } } public sealed class ParamRow { public string Name { get; set; } public double Value { get; set; } }运行效果在 Creo 中打开一个带有参数的模型点击 C 菜单项启动 C# 窗体再点击“刷新”稍等片刻DataGridView 里就出现了模型的全部参数及对应数值。整个过程 C# 没有引入任何 Creo 相关的 SDK 引用完全通过管道通信完成。如果试下来发现没有数据返回先不要检查 C# 代码先检查三件事第一Creo 的辅助应用程序是否已经处于“正在运行”状态第二protk.dat 是否指向了正确的 DLL 路径第三管道名字是否两端一致。只要这三件事一致整个链路跑通只是时间问题。7. 调试技巧与扩展方向混合编程项目最难受的就是出了错不知道在哪里查。C 端崩溃、C# 端抛异常、Creo 端卡死三条线索混在一起如果没有系统性的调试方法排查效率会非常低。我常用的调试手段第一是在 C 端写日志文件目录放在 DLL 同级的logs文件夹下所有关键路径都记录时间戳、请求内容、返回码。不要迷信断点调试——Creo 进程里挂着调试器的感觉谁试谁知道极其难受而且有时断点本身会影响时序。第二是在 C# 端把通信原始报文显示在一个“调试输出”文本框里线上问题可以先让用户把文本框内容截图发过来80% 的问题一眼就能定位是序列化还是业务逻辑。如果项目继续往前推进我还有几个扩展方向可以做。一是把管道协议从简单的 JSON 行协议升级成带消息 ID 和超时重试机制的正式协议这会显著增强稳定性。二是在 C 端增加模型事件订阅比如模型保存、参数变更的通知推送给 C# 界面让 UI 实时刷新。三是把 C# 端的 UI 框架从 WinForms 升级为 WPF 或 .NET 6/8 的现代 UI配合异步绑定用户体验会好很多。这套架构的可扩展性相当不错因为我人为把 C 和 C# 之间的依赖压到了很小的接口面上。C# 端不懂 Creo 也能开发C 端不懂界面也能干活团队分工可以非常清晰。最后再分享一个个人体会混合编程项目里 70% 的问题不是算法不够巧妙而是工程基础不牢。版本匹配、位数、线程模型、编码这些看起来“没有技术含量”的东西恰恰决定了一个项目能不能活过一周。把这套基础做扎实了C 和 C# 其实可以配合得非常舒服。