Unity跨平台剪切板复制:三端适配与WebGL安全策略详解

1. 项目概述:为什么Unity的复制功能值得深究?

做Unity开发这么多年,从PC单机到WebGL,再到移动端,我几乎在每个项目里都遇到过需要“复制文本到剪切板”的需求。听起来简单,不就是一行GUIUtility.systemCopyBuffer的事吗?但真上手做,尤其是要兼容PC(Windows/Mac)、WebGL和移动端(Android/iOS)这三端时,坑是一个接一个。新手可能会直接抄一段网上流传的通用代码,结果在某个平台上死活不生效,或者触发安全警告,用户体验直接崩盘。

这个功能的核心价值在于提升产品的交互流畅度和用户满意度。想象一下,你在游戏里生成了一个分享码,或者在编辑器工具里生成了一个配置字符串,用户点一下按钮就能复制,无需长按选中再手动复制,这种无缝体验对留存率有实实在在的帮助。但Unity作为一个跨平台引擎,并没有提供一个在所有环境下都绝对可靠的剪切板API,这就需要我们开发者根据不同的运行时环境,去适配不同的底层实现。

网上能找到的代码片段往往只解决了某一端的问题,或者使用了已被废弃的API。本文将彻底拆解在Unity中实现稳健的跨平台(三端)复制到剪切板功能,不仅告诉你“怎么做”,更会深入每个平台背后的原理、权限要求、安全限制和那些文档里不会写的“坑”。我会基于一个实际可用的、经过多个项目检验的ClipboardUtility工具类来展开,让你抄作业也能抄得明明白白。

2. 核心思路与方案选型:为何没有“银弹”?

实现剪切板功能,本质上是在与操作系统或浏览器的底层API打交道。Unity的GUIUtility.systemCopyBuffer属性在Windows和Mac的独立应用(Standalone)环境下是有效的,因为它直接调用了操作系统的剪切板接口。然而,一旦脱离这个环境,它就失灵了。

2.1 各平台的技术壁垒分析

  1. WebGL平台:这是最大的挑战。浏览器出于安全考虑,对剪切板的访问有严格限制。现代浏览器普遍遵循W3C Clipboard API,但要求操作必须由用户手势(如点击)直接触发,并且可能需要明确的用户授权。你不能在异步回调或者定时器里静默操作剪切板。
  2. Android/iOS平台:移动端的情况类似,需要调用原生(Native)插件。Android通过AndroidJavaClass调用android.content.ClipboardManager,iOS则需要通过Objective-C/Swift桥接调用UIPasteboard。这里涉及到与原生代码的交互和平台依赖。
  3. Windows/Mac独立应用:相对最简单,GUIUtility.systemCopyBuffer或.NET的TextEditor/GUIUtility.systemCopyBuffer即可。但需要注意线程安全,以及在某些Linux发行版上的兼容性问题(虽然Unity官方对Linux桌面支持有限)。

2.2 我们的方案:分层与降级

基于以上分析,一个健壮的方案不能是单点实现,而必须是一个分层、有降级策略的系统。我的设计思路是:

  • 优先级1:使用平台专属的最高效、最稳定的方法。
  • 优先级2:当专属方法不可用时,尝试通用的备用方案。
  • 优先级3:所有方案都失败时,提供明确的用户反馈(如弹窗提示手动复制)。

具体到代码层面,我们将创建一个ClipboardUtility静态类,在其CopyToClipboard方法内部,通过运行时平台判断,分发到不同的私有方法去执行。对于WebGL,我们将同时准备新旧两套API方案以兼容不同浏览器。对于移动端,我们将编写简单的原生插件接口。

注意:在WebGL平台,任何剪切板操作都必须紧密绑定在一个由用户触发的UI事件(如PointerDownPointerUp)的处理函数中。如果你在async/await链的深处或者一个延迟调用的函数里操作,99%会失败并被浏览器静默阻止。

3. 分平台实现细节与核心代码解析

接下来,我们进入实战环节,逐一拆解每个平台的实现细节。我会先给出整合后的工具类全景,再分平台深入。

3.1 工具类骨架与统一入口

首先,我们建立这个工具类的框架。它提供了一个干净的静态方法供业务方调用。

using UnityEngine; using System.Runtime.InteropServices; // 用于WebGL的DllImport public static class ClipboardUtility { /// <summary> /// 将指定文本复制到系统剪切板。 /// </summary> /// <param name="text">要复制的文本</param> /// <returns>操作是否成功(在某些平台如WebGL旧API下可能不准确)</returns> public static bool CopyToClipboard(string text) { if (string.IsNullOrEmpty(text)) { Debug.LogWarning("[ClipboardUtility] 尝试复制空文本到剪切板。"); return false; } bool success = false; // 根据运行平台选择不同的实现 #if UNITY_WEBGL && !UNITY_EDITOR success = CopyToClipboard_WebGL(text); #elif UNITY_ANDROID && !UNITY_EDITOR success = CopyToClipboard_Android(text); #elif UNITY_IOS && !UNITY_EDITOR success = CopyToClipboard_iOS(text); #else // 包括 Standalone (Win/Mac/Linux), Editor, 以及其他平台 success = CopyToClipboard_Standalone(text); #endif if (!success) { Debug.LogError($"[ClipboardUtility] 复制失败。文本内容已打印至控制台,请手动复制:\n{text}"); // 这里可以触发一个UI事件,提示用户“复制失败,请手动复制:XXX” } else { Debug.Log($"[ClipboardUtility] 文本已成功复制到剪切板。"); } return success; } // 各平台具体的实现方法将在下面分节详解 #if UNITY_WEBGL && !UNITY_EDITOR private static bool CopyToClipboard_WebGL(string text) { ... } #endif #if UNITY_ANDROID && !UNITY_EDITOR private static bool CopyToClipboard_Android(string text) { ... } #endif // ... 其他平台方法 }

这个入口方法做了几件关键事:参数校验、平台路由、统一日志和降级处理(失败时打印到控制台)。日志在调试时非常有用,尤其是在WebGL这种难以调试的环境下。

3.2 WebGL平台实现:与浏览器安全策略共舞

WebGL的实现最为复杂,需要同时处理现代API和传统方法。

#if UNITY_WEBGL && !UNITY_EDITOR // 方法1:使用现代的 Clipboard API (navigator.clipboard.writeText) // 通过JSLib注入到JavaScript环境 [DllImport("__Internal")] private static extern void ClipboardWriteText(string text); // 方法2:传统的document.execCommand('copy')方法作为降级方案 // 同样通过JSLib注入 [DllImport("__Internal")] private static extern bool ClipboardCopyViaExecCommand(string text); private static bool CopyToClipboard_WebGL(string text) { // 优先尝试现代API try { ClipboardWriteText(text); return true; // 注意:这里假设JS端调用成功,实际可能失败但无回调 } catch (System.Exception e) { Debug.LogWarning($"[ClipboardUtility] 现代Clipboard API调用失败,尝试降级方案。错误: {e.Message}"); } // 降级方案:使用传统的execCommand方法 try { bool result = ClipboardCopyViaExecCommand(text); return result; } catch (System.Exception e) { Debug.LogError($"[ClipboardUtility] 传统execCommand方案也失败。错误: {e.Message}"); return false; } } #endif

C#代码只是桥接,真正的逻辑在JavaScript文件(通常命名为*.jslib,放在Assets的Plugins/WebGL目录下)。这个文件是WebGL构建的一部分。

WebGL插件代码 (Assets/Plugins/WebGL/Clipboard.jslib):

mergeInto(LibraryManager.library, { ClipboardWriteText: function (textPointer) { // 将Unity传来的字符串指针转换为JS字符串 var text = UTF8ToString(textPointer); // 方法1: 使用现代的 Clipboard API (推荐) if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function() { console.log('[JSLib] 文本已通过现代API复制。'); }).catch(function(err) { console.error('[JSLib] 现代API复制失败:', err); // 这里无法将错误直接抛回C#,但可以记录。 }); return; // 现代API是异步的,直接返回 } // 方法2: 降级方案 - 传统的execCommand方法 // 关键:必须在一个由用户事件触发的回调中执行! var textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; // 避免滚动 textArea.style.opacity = "0"; document.body.appendChild(textArea); textArea.select(); // 选中文本 textArea.setSelectionRange(0, textArea.value.length); // 移动端兼容 var success = false; try { success = document.execCommand('copy'); console.log('[JSLib] 通过execCommand复制: ' + (success ? '成功' : '失败')); } catch (err) { console.error('[JSLib] execCommand执行出错:', err); } document.body.removeChild(textArea); // 清理DOM元素 // 将结果同步返回给C# return success; }, // 一个专门为execCommand降级方案准备的独立函数,逻辑同上 ClipboardCopyViaExecCommand: function (textPointer) { var text = UTF8ToString(textPointer); var textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; textArea.style.opacity = "0"; document.body.appendChild(textArea); textArea.select(); textArea.setSelectionRange(0, textArea.value.length); var success = false; try { success = document.execCommand('copy'); } catch (err) { console.error('[JSLib] ClipboardCopyViaExecCommand失败:', err); } document.body.removeChild(textArea); return success; } });

WebGL实操心得

  1. 用户手势是关键:务必确保你的CopyToClipboard方法是由Button.OnClick()PointerDown等直接用户交互事件调用的。通过协程WaitForSeconds后调用,100%失败。
  2. HTTPS要求:现代Clipboard API (navigator.clipboard) 通常在安全上下文(HTTPS或localhost)下才可用。如果你的WebGL部署在HTTP环境,可能只有execCommand降级方案有效。
  3. 异步与同步navigator.clipboard.writeText返回Promise,是异步的。我们的C#调用是“触发即忘”,无法直接获取异步结果。好在大多数情况下,如果权限不足,它会在浏览器控制台抛出错误。execCommand是同步的,可以直接返回成功与否。
  4. DOM操作execCommand方案需要操作DOM(创建textarea)。确保在操作完成后立即移除创建的临时元素,避免内存泄漏。

3.3 Android平台实现:通过Java接口调用系统服务

Android的实现相对直接,使用Unity提供的AndroidJavaClassAndroidJavaObject来调用Android SDK的API。

#if UNITY_ANDROID && !UNITY_EDITOR private static bool CopyToClipboard_Android(string text) { try { // 获取当前Activity的上下文 using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (AndroidJavaObject currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) { if (currentActivity == null) { Debug.LogError("[ClipboardUtility] 无法获取当前Android Activity。"); return false; } // 获取系统剪切板服务 using (AndroidJavaClass clipboardManagerClass = new AndroidJavaClass("android.content.ClipboardManager")) using (AndroidJavaObject clipboardService = currentActivity.Call<AndroidJavaObject>("getSystemService", "clipboard")) { if (clipboardService == null) { Debug.LogError("[ClipboardUtility] 无法获取Android ClipboardManager。"); return false; } // 创建ClipData对象 using (AndroidJavaClass clipDataClass = new AndroidJavaClass("android.content.ClipData")) using (AndroidJavaObject clipData = clipDataClass.CallStatic<AndroidJavaObject>("newPlainText", "label", text)) { // 将数据设置到剪切板 clipboardService.Call("setPrimaryClip", clipData); return true; } } } } catch (System.Exception e) { Debug.LogError($"[ClipboardUtility] Android复制失败: {e.Message}\n{e.StackTrace}"); return false; } } #endif

Android实操心得

  1. 主线程操作:剪切板操作必须在主线程(Unity游戏线程)进行。上述代码在Unity主线程中运行是安全的。
  2. 上下文(Context)getSystemService需要传入一个Context。我们通过UnityPlayer.currentActivity获取了当前应用的Activity上下文,这是标准做法。
  3. “Label”参数newPlainText方法的第一个参数是一个标签(label),用于描述剪切板内容,一些辅助功能应用可能会读取它。可以传入任意字符串,如“Copied Text”或你的应用名。
  4. 权限:在普通的Android应用中,写入剪切板不需要任何特殊的权限声明(<uses-permission>)。这比读取剪切板要简单得多。

3.4 iOS平台实现:通过Objective-C桥接

iOS需要通过[DllImport("__Internal")]调用原生Objective-C函数。我们需要一个.mm(Objective-C++)文件作为桥接。

C#端代码:

#if UNITY_IOS && !UNITY_EDITOR // 声明一个外部函数,它将调用我们编写的原生代码 [DllImport("__Internal")] private static extern void _CopyTextToClipboard(string text); private static bool CopyToClipboard_iOS(string text) { try { _CopyTextToClipboard(text); return true; // 同样,这里假设原生调用成功。更严谨的做法是让原生函数返回bool。 } catch (System.Exception e) { Debug.LogError($"[ClipboardUtility] iOS复制失败: {e.Message}"); return false; } } #endif

iOS原生桥接文件 (Assets/Plugins/iOS/ClipboardBridge.mm):

#import <UIKit/UIKit.h> #import <Foundation/Foundation.h> extern "C" { void _CopyTextToClipboard(const char *text) { if (text == nullptr) return; NSString *nsString = [NSString stringWithUTF8String:text]; if (nsString == nil || nsString.length == 0) return; // 获取系统的通用剪切板 UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; // 在iOS中,直接设置string属性即可。系统会自动处理多格式。 [pasteboard setString:nsString]; // 可选:为了调试,可以在Xcode控制台输出日志(发布时移除) // NSLog(@"[ClipboardBridge] Text copied: %@", nsString); } }

iOS实操心得

  1. 文件类型:必须是.mm扩展名,以支持C++和Objective-C混编(Unity的iOS插件需要)。
  2. 函数命名:C#中声明的函数名(_CopyTextToClipboard)必须与原生文件中的函数名完全一致。开头的下划线是常见的命名约定,但不是必须的。
  3. 内存管理:我们使用了stringWithUTF8String来转换字符串,它是自动释放的(Autorelease),在函数内使用是安全的。
  4. 剪切板对象[UIPasteboard generalPasteboard]获取的是系统级的通用剪切板,在应用内外都可用。
  5. 构建处理:这个.mm文件需要放在Assets/Plugins/iOS目录下,Unity在构建iOS项目时会自动将其包含到Xcode工程中。

3.5 独立平台(Standalone)与编辑器实现

这是最简单的部分,直接使用Unity的API。

private static bool CopyToClipboard_Standalone(string text) { try { // 方法1:使用GUIUtility (适用于所有独立平台和编辑器) GUIUtility.systemCopyBuffer = text; // 方法2:也可以使用TextEditor(更底层,但可能更可靠) // TextEditor editor = new TextEditor(); // editor.text = text; // editor.SelectAll(); // editor.Copy(); return true; } catch (System.Exception e) { Debug.LogError($"[ClipboardUtility] Standalone/Editor复制失败: {e.Message}"); return false; } }

Standalone实操心得

  1. 线程安全GUIUtility.systemCopyBuffer必须在主线程中调用。在Unity中,从MonoBehaviour事件(如OnClick)或主线程协程中调用是安全的。
  2. Linux考量:虽然Unity支持Linux构建,但某些桌面环境(如Wayland)下的剪切板行为可能与Windows/Mac略有不同。GUIUtility.systemCopyBuffer在大多数情况下是有效的,但如果你面向Linux发行版,建议进行针对性测试。
  3. 编辑器模式:在Unity Editor中运行游戏时,GUIUtility.systemCopyBuffer会将文本复制到你的操作系统剪切板,而不是某个“编辑器内部剪切板”。这非常便于调试。

4. 在Unity项目中的完整使用流程与集成示例

工具类写好了,怎么用到项目里呢?这里给出一个从UI按钮触发复制的完整示例。

4.1 创建UI并绑定脚本

  1. 在Unity中创建一个Canvas,添加一个Button和一个InputField(用于输入要复制的文本)。
  2. 创建一个C#脚本,命名为DemoCopyButton,挂载到Button上。
using UnityEngine; using UnityEngine.UI; // 需要引用UI命名空间 public class DemoCopyButton : MonoBehaviour { [SerializeField] private InputField sourceInputField; // 在Inspector中拖拽赋值 [SerializeField] private Text feedbackText; // 可选:用于显示操作反馈的Text组件 private Button _button; void Start() { _button = GetComponent<Button>(); if (_button != null) { // 将复制方法绑定到按钮的点击事件 _button.onClick.AddListener(OnCopyButtonClicked); } if (sourceInputField == null) { // 如果没指定InputField,尝试从同级或父级查找 sourceInputField = GetComponentInParent<InputField>(); } } void OnCopyButtonClicked() { string textToCopy = "默认文本"; if (sourceInputField != null && !string.IsNullOrEmpty(sourceInputField.text)) { textToCopy = sourceInputField.text; } else { // 示例:如果没有输入框,可以复制一个固定的内容,比如玩家ID或分享码 textToCopy = $"我的分享码是:{Random.Range(1000, 9999)}"; } bool success = ClipboardUtility.CopyToClipboard(textToCopy); // 给用户一个视觉反馈 if (feedbackText != null) { feedbackText.text = success ? "已复制到剪切板!" : "复制失败,请查看控制台。"; feedbackText.color = success ? Color.green : Color.red; // 2秒后清空反馈信息 CancelInvoke(nameof(ClearFeedback)); Invoke(nameof(ClearFeedback), 2f); } } void ClearFeedback() { if (feedbackText != null) { feedbackText.text = ""; } } }

4.2 平台特定设置与构建

  • WebGL:确保Clipboard.jslib文件位于Assets/Plugins/WebGL目录。构建时无需额外设置。
  • Android:无需额外权限。确保Player Settings中的Minimum API Level设置在合理范围(如API 21以上),以保证ClipboardManagerAPI可用。
  • iOS:确保ClipboardBridge.mm文件位于Assets/Plugins/iOS目录。构建生成Xcode工程后,通常无需额外配置。如果遇到链接错误,请检查文件是否被正确包含。

4.3 在非UI环境下的调用

复制功能不一定非要从UI按钮触发。例如,你可以在游戏逻辑中自动生成一段配置代码,然后提供给玩家一个复制按钮。

// 示例:在某个管理器类中 public class ShareCodeManager : MonoBehaviour { private string _generatedShareCode; void GenerateAndPrepareShareCode() { // ... 生成分享码的逻辑 _generatedShareCode = $"PLAYER-{System.DateTime.Now.Ticks}"; // 你可以将_generatedShareCode显示在UI的某个Text上 // 并且为该Text所在的GameObject附加一个按钮,按钮调用: // ClipboardUtility.CopyToClipboard(_generatedShareCode); } }

关键在于,触发复制操作的入口点(如按钮点击事件)必须是一个直接的用户交互,尤其是在WebGL平台。

5. 常见问题排查与实战避坑指南

即使按照上面的步骤做了,你可能还是会遇到问题。下面是我在多个项目中踩坑后总结的排查清单。

5.1 WebGL平台复制无效

这是最高频的问题。

  • 症状:点击按钮,控制台没有错误,但粘贴不出来内容。
  • 排查步骤
    1. 检查用户手势:这是首要原因。确保你的CopyToClipboard调用栈的源头是Button.onClick.Invoke()IPointerDownHandler.OnPointerDown等。绝对不要Start()Awake()、网络请求回调、Invoke协程WaitForSeconds的延迟部分首次触发复制。
    2. 打开浏览器开发者工具(F12)
      • 查看Console:是否有“NotAllowedError”或“Permission denied”错误?这可能是现代API因非安全上下文(非HTTPS)或无用户手势而拒绝。是否有“execCommand is not a function”警告?说明浏览器已禁用此API。
      • 切换到Network标签:确认你的页面是通过http://localhosthttps访问的。如果是普通的http://IP,现代Clipboard API可能被禁用。
    3. 测试降级方案:在我们的工具类中,如果现代API失败,会自动尝试execCommand。在浏览器Console中手动测试document.execCommand('copy')(需在用户事件处理函数中执行)是否有效。
    4. 检查JSLib:确认Clipboard.jslib文件存在且内容正确。构建后,可以检查生成的HTML文件,搜索ClipboardWriteText函数名,看是否被正确包含。

5.2 Android平台编译错误或运行时崩溃

  • 症状:构建APK时报错,或安装后点击复制按钮应用崩溃。
  • 排查步骤
    1. 检查Android API Level:过低的Minimum API Level可能导致找不到ClipboardManager类。建议设置为API 21(Android 5.0)或更高。
    2. 检查ProGuard混淆(如果启用):如果你在Player Settings中启用了Minify(代码混淆),可能会混淆掉Android Java接口。需要在proguard-user.txt中添加规则来保留UnityPlayer和相关的Android类。例如:
      -keep class com.unity3d.player.** { *; } -keep class android.content.ClipboardManager { *; } -keep class android.content.ClipData { *; }
    3. 查看Logcat日志:使用adb logcat或Unity Profiler(连接开发设备)查看崩溃时的详细错误信息,通常能定位到具体的Java异常。

5.3 iOS平台构建失败或运行无效果

  • 症状:Xcode编译失败,或应用运行后复制功能静默失败。
  • 排查步骤
    1. 检查.mm文件位置与内容:确认ClipboardBridge.mmAssets/Plugins/iOS下,并且代码中的函数名与C#的[DllImport]声明完全一致(包括下划线)。
    2. 检查Xcode工程:用Xcode打开Unity生成的工程,在LibrariesPlugins分组下查看,你的.mm文件是否被正确引入。如果没有,可以手动将其拖入工程。
    3. 检查函数签名:确保C#中的函数签名(返回void,参数string)与Objective-C函数(void _CopyTextToClipboard(const char *))匹配。
    4. 真机调试:在iOS真机上,剪切板操作是静默的。最好的调试方式是在Xcode中为原生函数添加NSLog输出,并在Xcode的Console中观察。也可以尝试复制后,切换到备忘录(Notes)应用,长按粘贴,看是否有内容。

5.4 通用问题:复制的内容格式不对或包含多余字符

  • 症状:复制出来的文本前后有换行、空格或乱码。
  • 排查步骤
    1. 检查源字符串:在调用CopyToClipboard前,用Debug.Log打印出要复制的字符串,观察其是否完全符合预期。注意\n\t、首尾空格等。
    2. WebGL的textarea方案:在execCommand方案中,我们创建了一个textarea。如果源文本本身包含HTML标签,它们会被当作纯文本复制,这通常是正确的。但如果你的文本来自一个富文本(Rich Text),可能需要先提取纯文本。
    3. 编码问题:在C#与JavaScript或原生代码间传递字符串时,确保使用UTF-8编码。我们使用的UTF8ToStringstringWithUTF8String都是处理UTF-8的,在绝大多数情况下是安全的。

5.5 性能与体验优化建议

  1. 避免频繁调用:剪切板操作是系统级调用,虽不重,但也不宜在每帧或高频事件中触发。
  2. 提供视觉反馈:如示例所示,复制成功或失败时,给用户一个即时的、清晰的UI反馈(如文字提示、图标变化、轻微动画)。因为操作是静默的,没有反馈用户会疑惑。
  3. 移动端长按菜单:在移动端,除了按钮,也可以考虑为显示文本的UI元素(如TextMeshPro)添加长按(Long Press)手势,触发复制操作,这更符合移动端用户习惯。
  4. 错误降级:我们的工具类在失败时将文本打印到控制台。在产品中,可以设计一个更友好的降级方案,例如弹出一个带有文本内容和“手动复制”提示的对话框。

6. 扩展思考:读取剪切板与更复杂的交互

本文聚焦于“写入”剪切板,这是最普遍的需求。但有时我们也会需要“读取”剪切板内容(例如,粘贴一个分享码来加入房间)。

  • 读取剪切板的挑战更大
    • WebGL:现代API是navigator.clipboard.readText(),它返回一个Promise。关键区别:读取操作一定会触发浏览器的权限弹窗,要求用户明确允许。且必须在安全的上下文中。
    • Android/iOS:同样需要调用原生API,并且可能需要处理权限(Android上读取剪切板通常不需要特殊权限,但iOS上读取通用剪切板是允许的)。
    • 安全与隐私:由于读取剪切板涉及用户隐私,所有平台都会更加严格。在实现前,务必评估其必要性,并在产品设计中明确告知用户。

一个完整的剪切板工具类可以同时包含CopyPaste方法,但实现Paste时,必须将平台差异和用户权限考虑得更加周全,并且做好读取失败(用户拒绝授权)的流程处理。

最后,这套三端复制方案已经在我的数个商业项目中稳定运行,涵盖了从休闲手游到复杂工具软件的多种场景。它的价值在于将平台的复杂性封装在一个简单的CopyToClipboard调用之后,让业务逻辑保持干净。当你下次需要在Unity项目中实现“一键复制”时,希望这份详细的指南能让你少走弯路,一次成功。