基于Qt与libvirt构建跨平台虚拟机管理器:从连接到显示

发布时间:2026/10/24 16:23:38
基于Qt与libvirt构建跨平台虚拟机管理器:从连接到显示 简介qt-virt-manager 是基于 Qt/C 开发的图形化虚拟机管理工具面向系统管理员与运维开发人员解决异构虚拟化平台之间管理分散、操作门槛高的问题。它通过统一界面覆盖 VMware、LXC、BHYVE、Libvirt、Hyper-V、OpenVZ、QEMU-KVM 与 VirtualBox 等主流技术用户无需在不同客户端之间来回切换即可集中创建、删除、启动、停止和监控虚拟机并进行资源分配、网络配置、克隆、备份与迁移等操作。压缩包大小约 3.08MB便于快速获取目前已有 353 人学习浏览。针对各底层虚拟化技术Qt 界面下的操作思路对理解 LXC 容器与 BHYVE 原生虚拟化的差异、Libvirt 抽象层的作用、QEMU-KVM 硬件虚拟化加速原理以及 Hyper-V/OpenVZ 的适用场景都有帮助。借助 Qt 跨平台特性工具在 Linux、Windows 等桌面环境下提供一致体验适合需要整合多种虚拟化技术、简化日常管理流程的系统工程师也适合作为虚拟化入门学习与教学演示的配套资源。1. Qt虚拟机经理不用再羡慕virt-manager的图形化体验我最初拿到 qt-virt-manager 这套资源时第一反应是“它到底解决了什么”。毕竟桌面端早已有 virt-manager可它在技术栈上绑死了 GTK我想把它嵌进自己的 Qt 主程序、想换一套 QSS 皮肤、想跨平台复用同一套界面逻辑都等于从头再造轮子。这份用 Qt/C 重写的虚拟机经理底层依然走 libvirt把 QEMU/KVM、VirtualBox、LXC、bhyve、Hyper-V 这些后端统一收进来界面则换成完全可控的 Qt 控件。它解决的不只是“能看见虚拟机”而是让桌面开发者和运维能拿到一套可编译、可二次修改的虚拟化管理源码。适合谁适合正在做桌面虚拟化管理工具、或需要在自定义界面里操作虚拟机的朋友新手也能照着源码把连接链路跑通。2. 连接后端URI、驱动树和安全认证的拆解真正上手这套 Qt 虚拟机经理之前建议先把连接层拆明白。因为所有功能——列表、启动、快照、显示——都建立在那一条连接 URI 上。URI 写错一个斜杠后面全白搭。2.1 先弄清 libvirt 驱动树系统连接与用户会话连接libvirt 不是虚拟化软件本身它是一个统一管理接口。你用virConnectOpenAuth传进去的 URI决定它选择哪个驱动。驱动名来自编译进 libvirt 的那一组后端模块常见的有qemu、vbox、lxc、bhyve、hyperv、openvz、phyp等。注意URI 里的:///system和:///session两个路径代表完全不同的权限边界system走系统的 libvirtd 守护进程需要 root 或加入libvirt组session走当前用户自己的进程不需要 root但它只能管理用户自己名下的虚拟机。我用一个表把这组 URI 记下来排查的时候对着看很快后端URI 示例说明QEMU/KVMqemu:///system最常用连接系统级 libvirtdVirtualBoxvbox:///system通过 vbox 驱动管理 VirtualBox 实例LXC 容器lxc:///system管理本机 LXC 容器bhyvebhyve:///systemFreeBSD 上的 bhyve 虚拟机Hyper-Vhyperv:///system通过 WSMan 协议连 Windows Hyper-VOpenVZopenvz:///session管理 OpenVZ 容器IBM PowerVMphyp:///system连接 PowerVM 管理分区这套资源里默认走的是qemu:///system。你要把它改成 vbox 或 lxc不需要改界面代码只需要改连接配置项。我在 qt-virt-manager 的源码里看到它把 URI 作为配置字符串放到首选项页这个设计很实用——不要把一个后端写死在构造函数里运行时切换能省去重新编译。2.2 远程连接与认证ssh 和 tcp 两条路的参数差异跨机器管理虚拟机时URI 会带上认证方式。常见的有两种写法qemussh://userhost/system和qemutcp://host:16509/system。先说 ssh 这条。它依赖本机 ssh 客户端第一次连接时 libvirt 会通过 sshd 跳转到远端再用远端 libvirtd 的权限模型验证。好处是传输加密、认证简单因为你本机已经生成过 ssh key坑点是如果你用密钥而非密码登录libvirt 的认证回调会被跳过它直接走 ssh 层这时如果远端没有把本地公钥加进authorized_keys就会收到一个让人摸不着头脑的连接错误。再说 tcp 这条。它走 16509 端口通常要开启 SASL 认证。打开/etc/libvirt/libvirtd.conf把listen_tls 0、listen_tcp 1打开然后配置auth_tcp sasl。注意如果 libvirtd 是以 systemd 方式启动还得修改/etc/libvirt/libvirtd.socket的监听开关。我在实际配置时踩过一次明明设置了listen_tcp 1服务却不监听 16509 端口后来发现是新版本 libvirt 默认走 socket 激活需要在/etc/libvirt/libvirtd.conf里把listen_tcp相关注释放开并且重启服务。tcp 连接如果不开 SASL测试时勉强能连上但生产环境绝对不要这么干——libvirt 的 tcp 不加认证等于把主机的域管理接口裸奔在局域网里。2.3 C 侧封装连接virConnectOpenAuth 与 URI 校验qt-virt-manager 的连接层直接用 libvirt 的 C API用 Qt 的QString转成const char*传进去。最关键的是认证回调它决定你用什么方式完成 SASL/SSH 后的身份验证。我一般会这样封装// hypervisor_connection.cpp #include libvirt/libvirt.h #include libvirt/virterror.h #include QDebug // 认证回调把用户名和密码交给 libvirt static int authCallback(virConnectCredentialPtr cred, unsigned int ncred, void* userdata) { auto* pair static_castQPairQString, QString*(userdata); for (unsigned int i 0; i ncred; i) { if (cred[i].type VIR_CRED_AUTHNAME) { // 把 Qt 字符串拷贝到 libvirt 的缓冲里 QByteArray name pair-first.toUtf8(); cred[i].result strdup(name.constData()); cred[i].resultlen name.size(); } else if (cred[i].type VIR_CRED_PASSPHRASE) { QByteArray pass pair-second.toUtf8(); cred[i].result strdup(pass.constData()); cred[i].resultlen pass.size(); } else { return -1; // 不认识的凭据类型直接失败 } } return 0; } virConnectPtr openHypervisor(const QString uri, const QString user, const QString pass) { QPairQString, QString authPair(user, pass); virConnectAuth auth; auth.credtype nullptr; auth.ncredtype 0; auth.cb authCallback; auth.cbdata authPair; // 这里先尝试读写连接失败再退化为只读 virConnectPtr conn virConnectOpenAuth(uri.toUtf8().constData(), auth, 0); if (conn nullptr) { const virError* err virGetLastError(); qWarning() 连接失败: err-message; } return conn; }这段代码里有几个参数值得解释。VIR_CRED_AUTHNAME表示让 libvirt 回调来填写用户名VIR_CRED_PASSPHRASE是密码。cred[i].result必须用strdup分配内存libvirt 会自己释放。我一般不用QString::toLocal8Bit而是toUtf8因为 libvirt 对证书里的非 ASCII 用户名处理有历史包袱UTF-8 出错概率最低。打开连接之后不要顺手把virConnectClose写进析构里就完事。如果界面里有多个窗口共享同一条连接你要自己维护引用计数。libvirt 的virConnectPtr不是线程安全的多个线程同时使用同一个指针时必须加锁或保证串行访问。我的做法是连接对象放在一个单独的管理类里所有 libvirt 调用都经过这个类的同步方法避免在多个线程里直接操作同一个virConnectPtr。2.4 连接层参数速查flags、超时与日志virConnectOpenAuth的 flags 参数有三个常用值0表示读写连接VIR_CONNECT_RO表示只读VIR_CONNECT_NO_ALIASES表示不做 URI 别名解析。如果你只是做一个只读监控面板用VIR_CONNECT_RO就够了这样即便口令泄露也只能看不能改。超时参数不在这个函数里它由 libvirt 内部的网络超时和 SASL 协商超时决定默认比较久。我一般会在连接前设置环境变量LIBVIRT_DEFAULT_URI同时把域名解析和连接放到 Qt 的QRunnable里执行界面等 8 秒还没返回就提示用户检查网络。日志方面libvirt 默认只打印virError到 stderr你可以设置LIBVIRT_LOG_FILTERS来控制export LIBVIRT_LOG_FILTERS3.libvirt 4.libvirt.qemu export LIBVIRT_LOG_OUTPUTS1:file:/tmp/libvirt_qt.log3表示只记录 warning 级别的 libvirt 全局日志4.libvirt.qemu表示 qemu 驱动单独记 debug。这个区分很重要全局日志写太多会把文件撑爆qemu 驱动的 debug 才是排查 XML 生成问题的地方。3. 域对象操作从 XML 定义到生命周期封装连接建立之后下一个要处理的是域domain的增删改查。libvirt 把所有虚拟机定义抽象成 XMLqt-virt-manager 也需要按照这个思路设计数据模型——不要自己发明结构体存虚拟机配置直接把 XML 当作源数据界面只是它的视图。3.1 用 XML 定义一个最小可运行的 KVM 域一个最小域定义长这样domain typekvm nameqt-demo/name memory unitKiB2097152/memory currentMemory unitKiB2097152/currentMemory vcpu placementstatic2/vcpu os type archx86_64 machinepc-q35-6.2hvm/type boot devhd/ /os features acpi/ apic/ /features clock offsetlocaltime/ on_poweroffdestroy/on_poweroff on_rebootrestart/on_reboot on_crashrestart/on_crash devices disk typefile devicedisk driver nameqemu typeqcow2/ source file/var/lib/libvirt/images/qt-demo.qcow2/ target devvda busvirtio/ /disk interface typebridge source bridgebr0/ model typevirtio/ /interface graphics typespice port5900 autoportyes listen127.0.0.1/ /devices /domain注意几个参数的细节。内存单位是 KiB2097152是 2 GiB如果你写2会被解析成 2 KiB启动时直接内存不足。currentMemory表示当前分配量可以小于memory用来做内存热插拔预留。vcpu的placementstatic表示固定 vCPU 数量如果改成placementauto会让宿主机自动决定。虚拟机名称我建议只用字母、数字、连字符和下划线不要用中文。libvirt 对名称中的非 ASCII 字符支持不彻底尤其在使用virDomainLookupByName时某些版本会返回domain not found但用virConnectListAllDomains又能看到它这就是名称编码问题导致的索引不一致。3.2 生命周期方法封装创建、关闭、重启与强制结束qt-virt-manager 的生命周期操作都封装在一个DomainController类里对外暴露的是 Qt 风格的方法内部再转成 libvirt 调用// domain_controller.cpp #include libvirt/libvirt.h #include QDebug class DomainController { public: virDomainPtr domain nullptr; virDomainPtr defineFromXML(virConnectPtr conn, const QByteArray xml) { // 从 XML 定义但先不启动 virDomainPtr dom virDomainDefineXML(conn, xml.constData()); if (dom nullptr) { const virError* err virGetLastError(); qCritical() 定义失败: err-message; } return dom; } int start() { // 对已定义的域做启动 int ret virDomainCreate(domain); if (ret ! 0) { const virError* err virGetLastError(); qWarning() 启动失败: err-message; } return ret; } int shutdownByACPI() { // 模拟按电源键让 guest 内系统正常关机 int ret virDomainShutdownFlags(domain, VIR_DOMAIN_SHUTDOWN_ACPI_POWER_BTN); return ret; } int forceDestroy() { // 相当于拔电源危险操作需要二次确认 return virDomainDestroy(domain); } };virDomainDefineXML只定义不启动适合做配置校验virDomainCreateXML是定义加启动一步到位但前提是你的 XML 里没有引用尚未创建的存储卷。VIR_DOMAIN_SHUTDOWN_ACPI_POWER_BTN是模拟物理电源键Windows guest 看到的是“关机”事件比网络发 RPC 更通用如果你装了 qemu-guest-agent可以改用VIR_DOMAIN_SHUTDOWN_GUEST_AGENT这样能走 guest 内部的干净关机流程但前提是 agent 已运行。3.3 状态轮询与 Qt 事件循环不要在 GUI 线程里做阻塞调用这是我在读这套资源源码时最看重的一点。libvirt 的virDomainGetInfo和virDomainGetState都会阻塞如果直接在 QML 槽函数或 QWidget 按钮事件里调用虚拟机数量一多界面就卡。正确做法是放到工作线程里。比如这样// domain_worker.cpp #include QRunnable #include QThreadPool class RefreshDomainWorker : public QRunnable { public: virDomainPtr dom; void run() override { virDomainInfo info; if (virDomainGetInfo(dom, info) 0) { // 通过信号发回主线程更新界面 emit resultReady(info.state, info.nrVirtCpu); } } };这里的关键是QRunnable在QThreadPool里执行得到结果后用信号槽传回主线程。因为virDomainPtr来自同一个连接工作线程之间如果同时调用需要保证它们操作的是不同域对象或者对整个连接加互斥锁。我采取的方案是连接层有一个QMutex每次调用前lock调完再unlock性能损失不大但能避免同一连接下的未知崩溃。3.4 定义与启动的边界持久域和临时域用virDomainDefineXML创建的域是持久的重启宿主机后依然存在用virDomainCreateXML创建的域是临时的关闭虚拟化服务后自动消失。qt-virt-manager 的“新建虚拟机”页面里有两个选项很多新手会把“定义”和“创建”混淆导致每次重启宿主机后虚拟机列表变成空的。我在资源代码里看到它把两个操作分别做成了按钮如果用户选择了“临时域”界面会显示一个显眼的警告条这个交互值得借鉴。4. 显示输出接入SPICE 与 VNC 参数以及 Qt 窗口内嵌的几种做法管理虚拟机绕不开显示输出。qt-virt-manager 要承担的不只是“列表管理器”它还得把访客画面交给用户看。这一章我拆解显示协议选择和 Qt 集成方式。4.1 先选协议SPICE 与 VNC 的差异表在配参数前先做技术选型。VNC 和 SPICE 在虚拟化场景里的地位不一样对比项SPICEVNC图形传输支持多显示器、动态分辨率调整固定分辨率分辨率变更需要重建会话音频原生支持音频转发需要额外走 PulseAudio 端口剪贴板支持双向剪贴板依赖 viewer 实现通常不支持视频编码自动降级编码局域网表现好简单编码弱网表现一般客户端支持spice-gtk / remote-viewer几乎所有平台都有 VNC client如果你管理的虚拟机以 Linux 桌面为主而且宿主机与访客在同一局域网SPICE 是明显更好的选择。但如果访客是 Windows 且没装 SPICE guest toolsSPICE 会退化成 VNC这时不如直接配 VNC少一层折损。4.2 从域配置里取图形端口和密码解析 XML 而不是调用隐藏 API很多刚开始写 Qt 虚拟化管理的人会去找virDomainGetGraphics这样的函数我提醒一句libvirt 没有这个公开 API。正确做法是用virDomainGetXMLDesc把整个 XML 拉回来再解析graphics节点// graphics_parser.cpp #include libvirt/libvirt.h #include QDomDocument QString getGraphicsPort(virDomainPtr domain) { // 取带安全字段的完整 XML密码会被解出来 char* xml virDomainGetXMLDesc(domain, VIR_DOMAIN_XML_SECURE); if (xml nullptr) return QString(); QDomDocument doc; doc.setContent(QString::fromUtf8(xml)); free(xml); const QDomNodeList graphicsList doc.elementsByTagName(QStringLiteral(graphics)); for (int i 0; i graphicsList.size(); i) { QDomElement g graphicsList.item(i).toElement(); if (g.attribute(type) spice || g.attribute(type) vnc) { QString port g.attribute(port); QString pass g.attribute(passwd); // 这里可以发给显示页面去连 return port; } } return QString(); }VIR_DOMAIN_XML_SECURE这个 flag 必须带否则graphics节点里的passwd属性会被省略成空。解析时用 DOM 而不是正则是因为 XML 属性顺序可能变正则容易漏掉多graphics节点的情况。多显示器虚拟机通常有多个graphics标签你需要保留全部而不是只取第一个。4.3 在 Qt 窗口内呈现访客画面三种方案第一种方案是调用remote-viewer外部进程把它的窗口嵌入到 Qt 主界面。做法是用 Linux 窗口系统提供的嵌入接口把子进程的winId塞进QWidget::createWindowContainer。好处是实现简单不用管 SPICE 协议的细节坏处是跨平台性差macOS 或 Windows 上表现不一致而且remote-viewer必须预先安装。第二种方案是使用 spice-gtk 的 Qt 适配层核心类是SpiceSession和SpiceDisplay。它可以在 Qt 里直接渲染 SPICE 画面支持鼠标、键盘、剪贴板转发。代价是你得把整个 spice-gtk 库链进项目对资源要求较高。第三种方案是 VNC 的帧缓冲开源库比如 libvncclient你在 Qt 里用一个QWidget的子类接收帧缓冲数据然后转成QImage渲染。这个方案不依赖外部程序但视频编码和解码全得自己处理性能瓶颈明显。我的评价是如果只是做管理工具第一种最省事如果要做完整桌面体验第二种子码流最稳第三种只适合临时测试。4.4 图形参数调整autoport 与端口冲突autoportyes的意思是让 libvirt 自动分配一个可用端口但自动分配意味着你不知道具体端口必须在域启动后再去读 XML才能拿到真实端口号。这在拼接spice://127.0.0.1:5900连接地址时是必须的。如果你指定固定端口port5900那么多个虚拟机不能同时设置相同端口否则后启动的域会失败。我在资源里看到它封装了一个“等待端口就绪”的状态机域启动后每 500 毫秒轮询一次 XML 里的graphics端口最多等 30 秒超过就报错。这个设计很实用因为 QEMU 从创建到监听 socket 有延迟立即读端口会拿到空值。5. 避坑排查连接、权限、显示三类问题的根因清单这一章是我把常见问题整理成清单的部分。所有条目都遵循“现象 → 原因 → 解决”的顺序直接对应 qt-virt-manager 里最容易踩的五个坑。5.1 连接报错“认证失败”但用户名密码明明是对的现象使用qemussh://userhost/system连接远程宿主机程序提示authentication failed但在终端里用同样的账号执行virsh list --all却正常。原因SSH 密钥与 libvirt 认证回调的冲突。libvirt 通过 ssh 管道连接远端后如果远端 libvirtd 要求 SASL它会继续走virConnectAuth回调但本机 SSH 层已经用了密钥登录没有可交互输入的 TTYSASL 拿不到额外凭据。解决改用已经配置好的 SSH 密钥组合让整个链路不需要交互。在本地执行ssh-keygen并把公钥加到远端然后确认远端/etc/libvirtd/libvirtd.conf里auth_tcp设为none或者与 ssh 认证不冲突。如果你坚持要 SASL就要确保本机有可用的 keychain 代理而不是依赖回调输密码。5.2 启动域提示权限不足但用户已经加入了 libvirt 组现象普通用户在 qt-virt-manager 里能列出域但启动时报internal error: process exited或者cannot access /var/lib/libvirt/images/xxx.qcow2。原因用户虽然加入了libvirt组但磁盘镜像文件的权限是 600属于 root。libvirtd 启动 QEMU 进程时以 root 身份运行所以 root 自己能读写但用户会话对文件的访问仍然被系统权限拦截。libvirt组的权限只管 libvirt 的守护进程控制不管是宿主机文件系统权限。解决给镜像文件设置合理的 ACL例如把/var/lib/libvirt/images下所有 qcow2 文件的组改成libvirt并给组读写权限。另外一个隐蔽点AppArmor 对 QEMU 的访问控制可能未放行特定路径在 Ubuntu 上需要在/etc/apparmor.d/usr.sbin.libvirtd里添加路径规则并 reload否则启动会直接被拒绝。5.3 SPICE 黑屏或连接后一直转圈现象虚拟机域启动正常remote-viewer 连接 SPICE 地址看到黑屏或无限转圈但 VNC 连接同一个域却正常。原因SPICE 协议在 TLS 与非 TLS 端口之间切换时客户端用了错误的连接前缀。如果你在 libvirt 里没有配置 TLS只监听了普通端口客户端却用spice://默认走 TLS就会一直握手失败。另一个原因是 spice-guest-tools 没装guest 内没有 SPICE 的显卡驱动导致显示输出没有推送到 SPICE 通道。解决在 Qt 代码里拼接地址时显式使用spice://127.0.0.1:端口同时确认 libvirt 的graphics节点里没有开启tlsPort。如果 guest 是 Windows先安装 spice-guest-tools如果是 Linux确认 spice-vdagent 服务运行中。我还会用virsh domdisplay去对照实际生成的地址排除端口读错的可能。5.4 列表里能看到域但双击启动无响应现象qt-virt-manager 的域列表能正确显示所有虚拟机状态颜色也正常但双击启动一个 shutdown 状态的域时UI 没有任何反馈后台日志也没有 libvirt 错误。原因极可能是同时连接了多个 libvirt 驱动列表展示的数据来自qemu:///system而双击后的启动操作却因为代码内部 URI 写死或变量未更新被发送到另一个连接会话。更隐蔽的是界面上显示的域是持久域但启动操作调用的是virDomainCreate而不是virDomainCreateXML而当前域对象是从列表缓存里拿的对象过期。解决在程序初始化时只维护一个全局的连接状态值。域列表的每个条目都必须保存它所属的virConnectPtr指针双击时直接使用同一条连接。还要在启动前调用virDomainGetState刷新一次状态避免操作一个大缓存里已经失效的域对象。这个坑在虚拟化面板里非常常见尤其是实现了跨后端列表合并之后。5.5 状态轮询把界面卡死甚至是假死现象虚拟机数量超过 5 个时界面开始出现明显卡顿数量达到 20 个时按钮点击后要等好几秒才有反应。原因在住线程里做了同步轮询。virDomainGetInfo对每个域都要发一次 libvirt 调用如果虚拟机处于暂停或转换状态这个调用可能阻塞几百毫秒。Qt 主线程一旦阻塞整个事件循环停摆表现为窗口无响应。解决把所有轮询逻辑移进QThreadPool界面里只接收信号更新。轮询间隔至少保持 2 秒不要低于 1 秒否则 QEMU 和 libvirt 内部的锁竞争会拖慢原本正常的操作。我在资源里看到它还实现了“轮询暂停机制”当用户正在执行快照或迁移操作时自动拉长轮询间隔减少干扰。6. 进阶技巧把域列表接进 Qt 模型层让界面自己刷新做到这一步你已经能把连接、域操作、显示输出跑通。最后一个值得长期沿用的技巧是用QAbstractListModel管理域列表而不是在 QWidget 里直接 push 数据。这套模式的收益要等虚拟机数量变大、状态频繁变化时才能体会到模型驱动视图数据变化自动通知界面代码不用到处手动 update。一个简易的模型头文件长这样// domain_list_model.h #include QAbstractListModel #include QVector struct DomainItem { virDomainPtr dom; QString name; int state; // libvirt 的状态码 }; class DomainListModel : public QAbstractListModel { Q_OBJECT public: enum Role { DomainNameRole Qt::UserRole 1, StateRole, DomainPtrRole }; int rowCount(const QModelIndex parent QModelIndex()) const override; QVariant data(const QModelIndex index, int role) const override; QHashint, QByteArray roleNames() const override; private: QVectorDomainItem m_items; };核心思想是重写data()方法让角色返回对应字段。当轮询线程拿到新状态时不要整个模型重置只更新对应行并发送dataChanged信号。这样列表视图只重绘变化的行刷新成本大幅降低。我在实现时会额外加一个state到颜色的映射比如 1running用绿色5shutdown用灰色让用户一眼看出运行状态。从那以后我每次把 qt-virt-manager 部署到新环境都强制走一遍流程先验证virsh -c qemu:///system list --all能看到正常列表再启动一个测试域再用 qt-virt-manager 连接并执行一次启动和关闭最后用virsh domstate反向校验界面显示和实际状态一致。这套流程给过我很多次后悔药也让我少背了很多个“面板显示正常但虚拟机起不来”的黑锅。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询