资讯动态

Unity物体闪烁效果实现与Bug排查:从Shader到MaterialPropertyBlock的进阶方案

发布时间:2026/8/8 17:14:27 来源:尧图企业网站定制
1. 项目概述从“闪”到“稳”的进阶之路在Unity3D开发中物体闪烁效果是一个高频需求无论是用于指示可交互物品、表现能量波动还是作为角色受伤或技能释放的视觉反馈它都扮演着重要角色。然而很多开发者尤其是刚接触Shader或动画系统的朋友在实现这个看似简单的效果时往往会踩进一个又一个的“坑”。你可能遇到过这样的场景一个需要周期性闪烁的警报灯在运行几秒后突然“卡”住变成了一个半亮不暗的奇怪状态或者一个角色的高亮闪烁在移动视角或与其他特效叠加时出现了令人不适的顿挫感和画面撕裂更棘手的是当你精心使用Shader Graph制作了一个复杂的PBR材质并试图为其添加闪烁时效果却完全失效物体纹丝不动。这些就是典型的“材质闪烁Bug”它们不仅破坏了游戏体验也让调试过程变得异常痛苦。本文将从一个资深TA技术美术和程序的角度深入拆解Unity中实现物体闪烁效果的多种方案及其底层原理并重点剖析那些导致闪烁失效、卡顿、异常的常见Bug及其根因最终提供一套稳定、高效且可复用的进阶解决方案。无论你是正在被闪烁Bug困扰的开发者还是希望优化现有视觉效果的技术美术这篇文章都将为你提供从原理到实践的完整指南。2. 核心需求解析与方案选型在动手写代码之前我们必须明确“物体闪烁”这个需求背后的不同层次。一个鲁棒的闪烁效果不仅仅是让物体的明暗变化它需要兼顾性能、视觉效果的一致性与可控性。2.1 闪烁效果的本质与分类从渲染管线的角度看物体闪烁本质上是其表面材质某个或某些视觉属性如颜色、自发光强度、透明度、法线扰动等随时间周期性变化的过程。根据驱动方式和影响范围我们可以将其分为以下几类基于材质属性动画的闪烁这是最直观的方法通过脚本或Animator动态修改材质的_Color、_EmissionColor等属性。优点是简单直接适用于任何材质。缺点是性能开销与物体数量正相关且难以与复杂的材质节点如Shader Graph深度结合。基于Shader Time节点的闪烁在Shader内部利用_Time等内置时间变量驱动一个正弦或锯齿波函数进而影响输出颜色或透明度。优点是性能极佳计算在GPU端效果平滑与材质本身融合度高。缺点是状态由Shader内部逻辑决定外部脚本难以进行精细的启停和节奏控制。基于屏幕后处理Post-processing的闪烁通过后处理滤镜对特定物体通常通过Render Feature或Stencil Buffer标记或整个屏幕的某个区域进行闪烁处理。优点是可以实现全局性的、与物体材质无关的复杂效果如外发光闪烁。缺点是开销较大且对单个物体的针对性控制较复杂。对于大多数游戏内物体如可拾取物品、机关、角色状态指示器基于材质属性动画和基于Shader Time节点是两种最核心、最常用的方案。而我们所遇到的大部分Bug也恰恰集中在这两种方案的实现细节和交互过程中。2.2 方案选型背后的考量为什么混合方案更可靠单纯使用脚本控制材质属性在物体不多时没问题但当场景中有上百个需要独立控制闪烁的物体时每帧更新每个物体的材质属性将成为CPU端的性能瓶颈。而完全依赖Shader内部的_Time又会让游戏逻辑如“玩家拾取后停止闪烁”难以介入。因此一个进阶的、工业级可靠的方案往往是“混合驱动”GPU端Shader负责生成平滑、周期性的波形信号如利用_SinTime或自定义计算。CPU端脚本负责传递控制参数如闪烁频率、强度、启用/禁用标志并处理游戏逻辑事件。这种分工将计算密集型的时间函数放在GPU将逻辑控制放在CPU兼顾了性能与灵活性。接下来我们将深入两种最常见方案的实现细节并逐一揭示那些潜伏的Bug。3. 方案一深度剖析基于材质属性动画的闪烁及其致命陷阱这是新手最常走的路写一个MonoBehaviour脚本挂在物体上在Update()中修改Material.color。3.1 基础实现与隐藏的GC陷阱一个典型的基础实现代码如下public class BasicFlicker : MonoBehaviour { public float frequency 1.0f; // 闪烁频率 private Renderer rend; private Color originalColor; void Start() { rend GetComponentRenderer(); originalColor rend.material.color; // 【陷阱1】 } void Update() { float lerpFactor (Mathf.Sin(Time.time * frequency * Mathf.PI * 2) 1) / 2; Color flickerColor Color.Lerp(Color.black, originalColor, lerpFactor); rend.material.color flickerColor; // 【陷阱2】 } }这段代码虽然能让物体闪烁但埋下了两个严重的性能与Bug隐患陷阱1rend.material的属性获取器。每次访问rend.materialUnity都会在底层自动复制一份新的材质实例Material Instance出来。如果你有100个物体在闪烁每帧就会产生100次材质复制和100次旧的材质实例的垃圾回收GC。短时间内就会引发剧烈的GC垃圾回收卡顿这是性能的“头号杀手”。陷阱2未考虑材质共享。通过rend.material获取并修改的是这个Renderer独有的材质实例。但如果你的项目中有很多物体使用同一个材质球Material Asset以节省Draw Call这种修改会导致“材质属性污染”。即你原本只想让A物体闪烁结果所有使用同一材质球的B、C、D物体都跟着一起闪烁了这显然是错误的。3.2 正确实现与材质管理策略修正后的代码必须解决上述两个问题public class OptimizedMaterialFlicker : MonoBehaviour { public float frequency 1.0f; public Color flickerToColor Color.black; private Renderer rend; private MaterialPropertyBlock mpb; // 关键工具材质属性块 private int colorShaderID; private Color originalColor; void Start() { rend GetComponentRenderer(); originalColor rend.sharedMaterial.color; // 使用sharedMaterial获取原始颜色 mpb new MaterialPropertyBlock(); // 创建属性块 colorShaderID Shader.PropertyToID(_Color); // 获取属性ID提升效率 // 初始化属性块设置初始颜色 rend.GetPropertyBlock(mpb); mpb.SetColor(colorShaderID, originalColor); rend.SetPropertyBlock(mpb); } void Update() { float lerpFactor (Mathf.Sin(Time.time * frequency * Mathf.PI * 2) 1) / 2; Color currentColor Color.Lerp(flickerToColor, originalColor, lerpFactor); // 使用属性块修改颜色不影响共享材质 rend.GetPropertyBlock(mpb); mpb.SetColor(colorShaderID, currentColor); rend.SetPropertyBlock(mpb); } }核心改进点使用MaterialPropertyBlock这是解决材质共享与性能问题的银弹。它允许你直接修改Renderer的材质属性而无需创建或修改材质实例本身。所有使用同一材质的物体可以独立拥有不同的属性值。使用Shader.PropertyToID将属性名称字符串如“_Color”转换为整数ID。这是一个一次性的开销在Update循环中使用整数ID比使用字符串查找属性要高效得多。使用sharedMaterial读取初始值在Start中我们通过sharedMaterial读取材质资源Asset的初始颜色避免触发材质实例化。注意MaterialPropertyBlock并非万能。它只能设置那些在Shader中定义为PerRendererData或通过标准方式暴露的属性。对于某些复杂的、通过材质编辑器自定义的节点网络直接通过属性块修改可能不生效这时就需要回到方案二或使用自定义Shader。3.3 常见Bug 1颜色重置失败与“卡住”状态Bug现象物体闪烁后在应该停止恢复为原色时却“卡”在了一个中间亮度或奇怪的颜色上。根因分析生命周期管理不当在闪烁脚本被禁用或物体被销毁时没有将材质颜色重置回原始状态。如果你的闪烁逻辑在某个条件如玩家靠近下启用在条件失效时禁用脚本那么Update()中的颜色修改就会停止物体将停留在最后一帧设置的颜色上。协程Coroutine被意外中断很多开发者喜欢用协程实现闪烁例如while(isFlickering) { 改变颜色; yield return new WaitForSeconds(interval); }。如果在协程执行过程中物体被禁用、销毁或者脚本被禁用而协程中没有相应的中断处理逻辑就可能造成状态不一致。解决方案务必在OnDisable()或OnDestroy()方法中重置材质属性。如果使用属性块重置同样需要通过属性块进行。void OnDisable() { if (rend ! null mpb ! null) { rend.GetPropertyBlock(mpb); mpb.SetColor(colorShaderID, originalColor); // 重置为原始颜色 rend.SetPropertyBlock(mpb); } }如果使用协程确保在协程内部检查对象有效性并使用CancellationToken或类似的标志位来安全地停止协程。4. 方案二深度剖析基于Shader Time的闪烁与复杂材质的兼容性对于性能要求极高或需要与材质效果深度集成的场景将闪烁逻辑写入Shader是最优雅的方案。4.1 标准Shader中的实现在一个Surface Shader或Standard Shader的片段着色器中可以这样实现// 在CGPROGRAM片段内 float flicker (sin(_Time.y * _FlickerFrequency * 6.283) 1.0) * 0.5; // 生成0-1的波形 float3 finalColor lerp(_FlickerColor.rgb, originalColor.rgb, flicker); o.Albedo finalColor;这里_Time.y是自游戏开始以来的时间秒_FlickerFrequency是一个由脚本传递的频率属性。4.2 与Shader Graph的集成为何有时会失效这是Bug的重灾区。很多人在Shader Graph中连接了一个Time节点到Color的插值上但在运行时材质毫无反应。Bug根因分析暴露参数未正确绑定在Shader Graph中你创建了一个Flicker_Factor参数并在脚本中试图通过material.SetFloat(Flicker_Factor, value)来修改它。但如果Shader Graph中该参数并未最终影响到主颜色的输出通道例如你连接了但连接线后来被意外断开或覆盖修改自然无效。必须确保参数节点最终流向了Master Node的Base Color或Emission等端口。使用错误的材质实例如同方案一的陷阱2如果你在修改sharedMaterial的属性而该材质被多个物体共享你的修改会影响所有物体。更糟的是如果你在Shader Graph中使用了Unique Material选项或者通过脚本错误地实例化了材质可能会导致属性绑定丢失。Shader Graph的Property引用名与脚本中的字符串不匹配Shader Graph中属性的“Reference”名称是大小写敏感的且默认会带有一些前缀。你必须使用和Reference完全一致的字符串名。排查与解决方案检查节点链路在Shader Graph编辑器中仔细检查从Time节点、乘法器、正弦节点、插值节点到最终颜色输出的整个链路是否畅通无阻。可以临时将某个中间节点连接到Master Node的某个测试端口如Position来验证其是否有输出。使用正确的属性名在Shader Graph中选中你的参数节点在Graph Inspector面板中查看其“Reference”全名如_Flicker_Frequency。在脚本中必须使用这个全名。使用MaterialPropertyBlock同样适用对于Shader Graph暴露的参数使用MaterialPropertyBlock来修改依然是最佳实践可以避免材质实例化问题。获取属性ID时同样使用完整的Reference名。int freqID Shader.PropertyToID(_Flicker_Frequency); mpb.SetFloat(freqID, 5.0f);4.3 常见Bug 2闪烁节奏不流畅与顿挫感Bug现象闪烁看起来一卡一卡的不是平滑的脉动尤其是在低帧率或时间缩放Time.timeScale变化时。根因分析在Update中使用Time.deltaTime进行累加这是一个经典错误。闪烁的相位phase应该基于一个连续、稳定的时间源。Time.time或_Time.y是理想选择。如果使用float timer Time.deltaTime;然后在Mathf.Sin(timer)中使用一旦帧率波动Time.deltaTime就会波动导致正弦函数的输入不均匀产生频率漂移和顿挫感。帧率依赖的插值在Update中直接使用Mathf.PingPong(Time.time, 1.0f)这类函数是没问题的因为Time.time是连续的。但如果你用Color.Lerp(colorA, colorB, pingPongValue)而pingPongValue的计算与帧率无关那么闪烁就是平滑的。问题往往出在将基于时间的波形映射到颜色变化时逻辑写在了受帧率影响的循环中。多个物体的时间不同步如果每个物体都用自己独立的计时器开始闪烁当大量物体同时闪烁时微小的启动时间差异会导致视觉上的杂乱有时也会被感知为“卡顿”。解决方案始终使用绝对时间在Shader中使用_Time.y在C#脚本中使用Time.time或Time.unscaledTime如果希望闪烁不受游戏暂停影响作为波形函数的输入。在GPU端计算将波形计算完全放在Shader中这是最平滑的方式因为_Time变量由Unity引擎每帧统一提供绝对连续。同步多个物体如果需要多个物体同步闪烁可以让它们共享一个时间基准。例如由一个管理器脚本计算全局的闪烁因子然后通过MaterialPropertyBlock分发给所有物体。// 在管理器中 public static float GlobalFlickerFactor { get { return (Mathf.Sin(Time.time * globalFrequency * Mathf.PI * 2) 1) / 2; } } // 在每个物体的脚本中 void Update() { mpb.SetColor(colorID, Color.Lerp(offColor, onColor, GlobalFlickerFactor)); rend.SetPropertyBlock(mpb); }5. 混合驱动方案实战稳定、可控的工业级实现结合前两章的分析我们设计一个混合驱动方案它利用Shader进行平滑计算同时保留CPU的完全控制权。5.1 Shader部分支持Standard和URP/HDRP Shader Graph我们创建一个支持混合驱动的Shader或Shader Graph子图SubGraph。对于手写Shader.shader文件Shader Custom/FlickerAdvanced { Properties { _MainTex (Albedo (RGB), 2D) white {} _MainColor (Color, Color) (1,1,1,1) _FlickerColor (Flicker Color, Color) (0,0,0,1) _FlickerFrequency (Frequency, Float) 1.0 _FlickerAmount (Amount, Range(0,1)) 0.5 // 由脚本控制0为无闪烁1为全效闪烁 _UseExternalControl (Use External Control, Float) 0 // 开关0用内部Time1用外部_FlickerAmount } SubShader { Tags { RenderTypeOpaque } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; fixed4 _MainColor; fixed4 _FlickerColor; float _FlickerFrequency; float _FlickerAmount; float _UseExternalControl; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c tex2D (_MainTex, IN.uv_MainTex) * _MainColor; // 计算内部闪烁因子 float internalFlicker (sin(_Time.y * _FlickerFrequency * 6.283) 1.0) * 0.5; // 根据开关选择最终因子 float finalFlickerFactor lerp(internalFlicker, _FlickerAmount, _UseExternalControl); // 应用闪烁 fixed3 finalColor lerp(_FlickerColor.rgb, c.rgb, finalFlickerFactor); o.Albedo finalColor; o.Alpha c.a; } ENDCG } FallBack Diffuse }对于Shader Graph创建以下Property_FlickerAmount(Vector1, Range(0,1))_UseExternalControl(Float)_FlickerFrequency(Float)_FlickerColor(Color)构建节点网络Time节点 - Multiply (乘以_FlickerFrequency和 2*PI) - Sine - Remap (从[-1,1]到[0,1])输出为Internal_Flicker。Lerp节点A端口连接Internal_FlickerB端口连接_FlickerAmountT端口连接_UseExternalControl输出为Final_Factor。另一个Lerp节点A端口连接_FlickerColorB端口连接你的主颜色T端口连接Final_Factor输出连接到Master Node的Base Color。5.2 C#控制脚本这个脚本将安全、高效地控制上述Shader。using UnityEngine; [RequireComponent(typeof(Renderer))] public class AdvancedFlickerController : MonoBehaviour { [Header(闪烁配置)] public Color flickerColor Color.red; public float frequency 2.0f; [Range(0, 1)] public float flickerAmount 0.5f; public bool useExternalControl false; [Header(高级控制)] public bool startFlickeringOnAwake true; public AnimationCurve flickerIntensityCurve AnimationCurve.Linear(0,0,1,1); // 可用于非正弦波闪烁 // 属性ID缓存 private static readonly int FlickerColorID Shader.PropertyToID(_FlickerColor); private static readonly int FlickerFrequencyID Shader.PropertyToID(_FlickerFrequency); private static readonly int FlickerAmountID Shader.PropertyToID(_FlickerAmount); private static readonly int UseExternalControlID Shader.PropertyToID(_UseExternalControl); private Renderer targetRenderer; private MaterialPropertyBlock mpb; private bool isFlickering false; void Awake() { targetRenderer GetComponentRenderer(); mpb new MaterialPropertyBlock(); // 初始化属性块设置静态属性 targetRenderer.GetPropertyBlock(mpb); mpb.SetColor(FlickerColorID, flickerColor); mpb.SetFloat(FlickerFrequencyID, frequency); mpb.SetFloat(UseExternalControlID, useExternalControl ? 1.0f : 0.0f); targetRenderer.SetPropertyBlock(mpb); if (startFlickeringOnAwake) { StartFlickering(); } } void Update() { if (!isFlickering) return; // 如果需要外部控制则更新FlickerAmount if (useExternalControl) { // 这里可以使用曲线、随机数或其他逻辑来计算amount // 示例简单的正弦波控制与Shader内部计算逻辑一致但由CPU驱动 float sinValue Mathf.Sin(Time.time * frequency * Mathf.PI * 2); float calculatedAmount (sinValue 1f) * 0.5f; // 应用强度曲线 calculatedAmount flickerIntensityCurve.Evaluate(calculatedAmount); flickerAmount calculatedAmount; targetRenderer.GetPropertyBlock(mpb); mpb.SetFloat(FlickerAmountID, flickerAmount); targetRenderer.SetPropertyBlock(mpb); } } public void StartFlickering() { isFlickering true; useExternalControl true; // 开始闪烁时切换到外部控制模式 targetRenderer.GetPropertyBlock(mpb); mpb.SetFloat(UseExternalControlID, 1.0f); targetRenderer.SetPropertyBlock(mpb); } public void StopFlickering() { isFlickering false; useExternalControl false; // 停止闪烁切回Shader内部Time驱动但amount为0即不闪烁 flickerAmount 1.0f; // 设置为1表示完全显示原色 targetRenderer.GetPropertyBlock(mpb); mpb.SetFloat(UseExternalControlID, 0.0f); mpb.SetFloat(FlickerAmountID, flickerAmount); targetRenderer.SetPropertyBlock(mpb); } void OnDisable() { // 确保物体被禁用时停止闪烁并重置状态 StopFlickering(); } }5.3 方案优势总结性能最优波形计算主要在Shader中完成当useExternalControlfalse时CPU只负责传递控制开关和强度参数开销极小。控制灵活通过useExternalControl开关可以在“自动平滑闪烁”和“由游戏逻辑驱动的任意闪烁模式”之间无缝切换。flickerIntensityCurve参数允许你设计非正弦的闪烁模式如警报灯的急促闪烁。状态安全提供了明确的StartFlickering()和StopFlickering()接口并在OnDisable中自动清理彻底避免了物体“卡住”在半亮状态的问题。材质友好全程使用MaterialPropertyBlock对材质实例化和Draw Call合并友好适合大量物体使用。6. 疑难杂症排查与性能优化指南即使采用了最佳实践在复杂的项目环境中闪烁效果仍可能遇到一些古怪问题。本章将列出这些“疑难杂症”及其排查思路。6.1 Bug 3在URP/HDRP中闪烁效果异常或消失现象在Built-in Render Pipeline中正常的闪烁Shader升级到URP或HDRP后效果不对或完全看不见。排查步骤检查Shader兼容性Built-in管线下的Surface Shader不能直接在URP中使用。你必须使用URP的Lit/Unlit Shader模板重写或使用Shader Graph。确保你的Shader是针对当前渲染管线编写的。检查渲染队列Render Queue和混合模式Blending如果你的闪烁涉及透明度 (_Color.a)在URP中需要明确设置渲染队列为Transparent并配置正确的混合模式如Blend SrcAlpha OneMinusSrcAlpha。在Built-in中可能默认生效的设置在URP中可能需要手动声明。检查URP渲染器特性Renderer Features某些后处理效果如某些Volume特效可能会修改整个画面的颜色或亮度干扰你的物体闪烁。尝试禁用所有Volume和Renderer Feature来排查。验证PropertyBlock在URP中的支持URP完全支持MaterialPropertyBlock。但如果你的Shader中属性定义方式特殊例如使用了CBUFFER需要确保属性在正确的Constant Buffer中声明。使用Shader Graph可以避免这个问题。6.2 Bug 4闪烁与光照、阴影的交互问题现象物体闪烁时其接收的阴影或产生的高光也一起闪烁或者闪烁效果在阴影中不可见。根因与解决只修改了Albedo颜色如果你的闪烁只影响了o.Albedo那么物体的发光颜色会变但它的阴影取决于物体的深度和遮挡和高光取决于法线和光滑度不会变这可能导致视觉上的不协调。一个更逼真的效果是让自发光 (o.Emission) 也一起闪烁模拟物体自身在发光。// 在surf函数中 o.Emission lerp(_FlickerColor.rgb * _EmissionStrength, fixed3(0,0,0), finalFlickerFactor);闪烁物体作为阴影投射体Caster如果你希望物体在“熄灭”时完全不投射阴影这涉及到修改物体的阴影投射Pass。这比较复杂通常不建议动态修改。一个取巧的办法是使用两个Mesh一个负责渲染一个负责投射阴影并分别控制。闪烁效果在阴影中太暗确保你的闪烁颜色计算是在最终光照计算之前。在Standard Shader的surf函数中o.Albedo是基础色它会与光照信息相乘。因此如果你把颜色lerp到黑色在阴影里它会变得更黑。如果你想要的是“自发光”效果应该主要操作o.Emission这样即使在阴影中发光部分也能保持亮度。6.3 性能优化要点批处理Batching与属性块使用MaterialPropertyBlock会打断动态批处理Dynamic Batching但不会影响静态批处理Static Batching和GPU Instancing。对于大量需要独立闪烁的物体启用GPU Instancing是更好的选择。你需要确保你的Shader支持GPU Instancing并在属性声明中添加[PerRendererData]标签或者使用支持Instancing的Shader变体。控制更新频率对于非关键性的环境闪烁如远处的一排小灯没必要每帧更新。可以每2-3帧更新一次闪烁参数或者使用一个统一的、缓慢变化的时间因子来驱动它们大幅降低CPU开销。使用Shader的顶点阶段Vertex Shader进行简单闪烁如果闪烁效果不需要每像素的精度例如只是整体明暗变化可以将计算移到顶点着色器。这样计算量更小虽然效果在模型顶点稀疏时可能有锯齿但对于大量低模物体是高效的。对象池与材质复用对于频繁生成和销毁的闪烁物体如子弹轨迹、爆炸火花使用对象池。并确保池中的物体在取出时其材质属性通过属性块设置被正确重置和重新配置避免出现“继承”了上一次闪烁状态的情况。7. 扩展应用闪烁效果的创意变体掌握了稳定的基础我们可以玩出更多花样。闪烁不仅仅是颜色的明暗交替。脉冲缩放Pulse Scaling通过修改物体的transform.localScale结合缓动函数如Mathf.PingPong或AnimationCurve实现物体有节奏的放大缩小常用于表现心跳、能量汇聚。注意缩放会影响碰撞体需同步更新或使用视觉与逻辑分离的方案。UV偏移闪烁在Shader中让材质的UV随时间偏移可以创建出“电流流过”、“能量扫描”的效果。这需要操作o.Albedo的纹理采样UV坐标。float2 uvOffset float2(_Time.y * _ScanSpeed, 0); fixed4 c tex2D(_MainTex, IN.uv_MainTex uvOffset) * _MainColor;法线扰动闪烁通过随时间变化的噪声图扰动法线贴图可以让物体表面产生一种“能量不稳定波动”的视觉效果。这需要采样噪声图并混合到o.Normal中。溶解边缘闪烁结合溶解DissolveShader让溶解边缘的颜色和宽度随时间闪烁常用于表现物体被腐蚀或传送时的特效。这需要控制溶解阈值和边缘颜色的参数。实现这些变体的核心思路不变将随时间变化的参数缩放系数、UV偏移量、噪声强度、溶解阈值通过高效的方式Shader内部Time或CPU驱动属性块传递给渲染管线并确保状态管理清晰无残留Bug。通过以上从原理到实践从基础到进阶从避坑到扩展的全面解析相信你已经对Unity中物体闪烁效果的实现与调试有了深刻的理解。记住一个稳定的视觉效果其背后是对渲染管线、材质系统、性能管理和状态机的周密考量。下次当你的物体再次“调皮”地乱闪时希望这份指南能帮你快速定位问题并优雅地解决它。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价