Unity通过Addressable + ILRuntime 实现代码和资产的热更新(案例+图文详情+源码)


前言

最近在看Unity的Addressable打包和ILRuntime的热更,顺便整理相关过程,记录一下。老规矩,先上效果图
请添加图片描述
主要演示的内容是通过代码和资产的热更,实现ui的如图交互逻辑。对Addressable 和 ILRuntime基础还不太了解的小伙伴可以先看下我之前的相关的系列文章,先了解下相关的技术,这篇文章是基于这两个的基础进行的。下面我就来详细的说下相关内容吧。


一、思路概述

我的思路是这样的:
把逻辑代码放到ILRuntime里面,项目主工程里面只有启动的入口,剩下的所有逻辑全部放到ILRuntime里面封装成dll文件。
执行顺序大概是这样的请添加图片描述
就是把具体要热更哪些资产,也放到ILRuntime里面去执行,主工程里面只是个入口。拿最终效果图来举例的话,就是说我可以随时热更里面的ui资产,随时热更ui交互逻辑。


二、Unity主工程部分

主工程里主要涉及这3块相关的代码

1.GameLunch的源码

在主工程里挂载了GameLunch用于游戏启动,加载dll,还会添加两个脚本,ResMgr 和 ILRuntimeWrapper

/*----------------游戏启动入口脚本-------------------------*/

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Security;
using System.Threading.Tasks;
using RenderHeads.Media.AVProVideo;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.Networking;

/// <summary>
/// 加载方式
/// </summary>
public enum LoadingMode
{
    ByLocalDll,
    ByLocalAddressable
}
public class GameLunch : MonoSingleton<GameLunch>
{
    
    
    
    //public Transform father;
    [Tooltip("dll文件的加载方式")]
    public LoadingMode loadingMode=LoadingMode.ByLocalAddressable;
    public override void Awake()
    {
        base.Awake();
        Caching.ClearCache();
        //初始化游戏框架
        //资源管理
        gameObject.AddComponent<ResMgr>();
        gameObject.AddComponent<ILRuntimeWrapper>();
        //gameObject.AddComponent<MediaPlayer>();
        StartHotFix();
        //LoadAddressables();

    }
    /// <summary>
    /// 测试加载AA
    /// </summary>
    /// <returns></returns>
    public async Task LoadAddressables()
    {
       //GameObject target= await Addressables.LoadAssetAsync<GameObject>("Canvas").Task;
       // var target = await ResMgr.Instance.GetAssetCache<GameObject>("Canvas");
       // GameObject.Instantiate(target);
       Caching.ClearCache();
       // var canvas= Addressables.InstantiateAsync("Canvas");
       // await canvas.Task;
       // await Addressables.InstantiateAsync("MainUIPanel",canvas.Result.transform).Task;
    }
    /// <summary>
    /// 加载dll
    /// </summary>
    /// <returns></returns>
    public async Task StartHotFix()
    {
        
        //去服务器上下载最新的aa包资源

        //下载热更代码
        //string m_url=null;
        byte[] dll=new byte[]{};
        byte[] pdb = new byte[] {};
        if (loadingMode == LoadingMode.ByLocalDll)
        {
            //StartCoroutine(CheckHotUpdate(dll,pdb));
        }
        else if (loadingMode==LoadingMode.ByLocalAddressable)
        {
            TextAsset assetDll= await Addressables.LoadAssetAsync<TextAsset>("HotFix_Project_dll_res").Task;
            dll = assetDll.bytes;
            TextAsset assetPdb= await Addressables.LoadAssetAsync<TextAsset>("HotFix_Project_pdb_res").Task;
            pdb = assetPdb.bytes;
            ILRuntimeWrapper.Instance.LoadHotFixAssembly(dll,pdb);
            
            
            
            ILRuntimeWrapper.Instance.EnterGame();
        }
        // ILRuntimeWrapper.Instance.LoadHotFixAssembly(dll,pdb);
        // ILRuntimeWrapper.Instance.EnterGame();
    }
    }

2.ResMgr的源码

主要是为热更工程提供了几个工具接口,用于获取unity的组件;通过addressable实例化ui;通过addressable加载资源到内存

using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class ResMgr : MonoSingleton<ResMgr>
{
    [SerializeField]
    public Transform parents;
    public List<string> loadPool;
    
    private string m_names;
    public override void Awake()
    {
        base.Awake();
        loadPool=new List<string>();
        parents = GameObject.Find("Canvas").transform;
    }
    /// <summary>
    /// 获取组件
    /// </summary>
    /// <param name="names">名字</param>
    /// <typeparam name="T">组件</typeparam>
    /// <returns></returns>
    public T GetTarget<T>(string names) where T : UnityEngine.Object
    {
        var target = GameObject.Find(names).GetComponent<T>(); 
        return target;
    }
    /// <summary>
    /// 通过addressable实例化ui
    /// </summary>
    /// <param name="names">ui预制体的名字</param>
    public async void InstantiateOfAddressables(string names) 
    {
        
           //var target = Addressables.InstantiateAsync(names,parents);
           //await target.Task;
           //loadPool.Add(names);
           //m_names = names;
           //ILRuntimeWrapper.Instance.IsGameStart = false;
           Addressables.InstantiateAsync(names, parents).Completed += (handle) => OnCompleted(handle,names);
           //ILRuntimeWrapper.Instance.finishLoadAction("主工程里面的委托@@@@");
           
    }

    private void OnCompleted(AsyncOperationHandle<GameObject> obj,string names)
    {
        Debug.Log($"{names}加载完了");
        obj.Result.name = names;
        //loadPool.Remove(names);
        //ILRuntimeWrapper.Instance.IsGameStart = true;
        //ILRuntimeWrapper.Instance.appDomain.Invoke("HotFix_Project.Main", "FixedUpdate", null, obj.Result.name);
        ILRuntimeWrapper.Instance.TestActionDelegate(names,obj.Result);
    }
    private void OnCompleted<T>(AsyncOperationHandle<IList<T>> obj,string names)where T :UnityEngine.Object
    {
        Debug.Log($"{names}加载完了");
        //obj.Result[0].name = names;
        //loadPool.Remove(names);
        //ILRuntimeWrapper.Instance.IsGameStart = true;
        //ILRuntimeWrapper.Instance.appDomain.Invoke("HotFix_Project.Main", "FixedUpdate", null, obj.Result.name);
        ILRuntimeWrapper.Instance.TestActionDelegate(names,obj.Result[0]);
        
        
    }
    /// <summary>
    /// 加载资源到内存
    /// </summary>
    /// <param name="names"></param>
    /// <typeparam name="T"></typeparam>
    /// <returns></returns>
    public async void LoadByAddressable<T>(string names)where T :UnityEngine.Object
    {
        //var target = await Addressables.LoadAssetsAsync<T>(names, null, Addressables.MergeMode.None).Task;
        Addressables.LoadAssetsAsync<T>(names, null).Completed+= (handle) => OnCompleted(handle,names);

    }
}

3.ILRuntimeWrapper的源码

这个里面把mono的生命周期反射到了ILruntime里面,详细的加载dll和pdb文件,还有就是委托转换器和委托适配器

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using ILRuntime.CLR.Method;
using ILRuntime.CLR.TypeSystem;
using UnityEngine;
using ILRuntime.Runtime.Enviorment;
using ILRuntime.Runtime.Intepreter;
using AppDomain = ILRuntime.Runtime.Enviorment.AppDomain;

public class ILRuntimeWrapper : MonoSingleton<ILRuntimeWrapper>
{
    public  Action<string,object> TestActionDelegate;

    public AppDomain appDomain;

    private string bindClass;
    private IType classType;
    private ILTypeInstance instance;
    private IMethod updateMethod,fixedUpdateMethod,lateUpdateMethod, awakeMethod, startMethod, onDestroyMethod;
    
    private System.IO.MemoryStream m_fs, m_p;
    private bool m_isGameStart,m_startUpdate;

    public bool IsGameStart
    {
        get => m_isGameStart;
        set => m_isGameStart = value;
    }

    public override void Awake()
    {
        base.Awake();
        IsGameStart = false;
        m_startUpdate = false;
        appDomain = new ILRuntime.Runtime.Enviorment.AppDomain();
    }

    private void FixedUpdate()
    {
        appDomain.Invoke(fixedUpdateMethod, instance);
    }

    private void Update()
    {
        if (IsGameStart)
        {
            appDomain.Invoke(updateMethod, instance);
        }
    }

    private void LateUpdate()
    {
        if (IsGameStart)
        {
            appDomain.Invoke(lateUpdateMethod, instance);
        }
    }

    private void OnDestroy()
    {
        if (IsGameStart)
        {
            appDomain.Invoke(onDestroyMethod, instance);
        }
    }

    /// <summary>
    /// 加载dll,pdb
    /// </summary>
    /// <param name="dll"></param>
    /// <param name="pdb"></param>
    public void LoadHotFixAssembly(byte[] dll, byte[] pdb)
    {
        m_fs = new MemoryStream(dll);
        //m_p = new MemoryStream(pdb);
        try
        {
            appDomain.LoadAssembly(m_fs, null, new ILRuntime.Mono.Cecil.Pdb.PdbReaderProvider());
        }
        catch
        {
            Debug.LogError("加载热更DLL失败,请确保已经通过VS打开Assets/Samples/ILRuntime/1.6/Demo/HotFix_Project/HotFix_Project.sln编译过热更DLL");
            return;
        }
        appDomain.DebugService.StartDebugService(56000);
        InitializeILRuntime();
    }
    
   private void InitializeILRuntime()
    {
#if DEBUG && (UNITY_EDITOR || UNITY_ANDROID || UNITY_IPHONE)
        //由于Unity的Profiler接口只允许在主线程使用,为了避免出异常,需要告诉ILRuntime主线程的线程ID才能正确将函数运行耗时报告给Profiler
        appDomain.UnityMainThreadID = System.Threading.Thread.CurrentThread.ManagedThreadId;
#endif
        //这里做一些ILRuntime的注册,HelloWorld示例暂时没有需要注册的
        //Action<string> 的参数为一个string
        Debug.Log("主工程里注册委托");
        appDomain.DelegateManager.RegisterMethodDelegate<string,object>();
        
        //unityAction的委托转换器
        appDomain.DelegateManager.RegisterDelegateConvertor<UnityEngine.Events.UnityAction>((act) =>
        {
            return new  UnityEngine.Events.UnityAction(() =>
            {
                ((Action)act).Invoke();
            });
        });
    }
    /// <summary>
    /// 进入游戏
    /// </summary>
   public void EnterGame()
   {
       //HelloWorld,第一次方法调用
       //appDomain.Invoke("HotFix_Project.InstanceClass", "StaticFunTest", null, null);
       appDomain.DelegateManager.RegisterMethodDelegate<string>();
       IsGameStart = true;
       //开始调用热更工程
       InitHotFixMethod();
       
       //开始执行热更工程
       //appDomain.Invoke("HotFix_Project.MainBehaviour","Awake",null,null);
   }
    public void InitHotFixMethod()
    {
        bindClass = "HotFix_Project.MainBehaviour";
        if (IsGameStart)
        {
            classType = appDomain.LoadedTypes[bindClass];
            instance = (classType as ILType).Instantiate();

            awakeMethod = classType.GetMethod("Awake", 0);
            startMethod = classType.GetMethod("Start", 0);
            updateMethod = classType.GetMethod("Update", 0);
            onDestroyMethod = classType.GetMethod("OnDestroy", 0);
            fixedUpdateMethod = classType.GetMethod("FixedUpdate", 0);
            lateUpdateMethod = classType.GetMethod("LateUpdate", 0);
            
            if (awakeMethod!=null)
            {
               appDomain.Invoke(awakeMethod, instance);
            }
        }
        //开始调用热更工程的start
        appDomain.Invoke(startMethod, instance);
    }
}

三、Addressable部分

之前一直在用AB包,了解到AA包,就尝试的用下,发现确实好用很多。

1.打AA包

1.分组问题

在这里插入图片描述

我是分成了3个部分来打的AA包,

Dll组 主要是热更工程的代码封装成的dll文件,
UI组 主要是几个uiPanel的Prefab
最后这个组是用的AA包自带的分析工具,自动生成的。

注意:AA包是无法直接打dll文件的,需要把dll文件转换成byte文件然后再打AA包,读取的时候再解析成dll文件。

2.打包和加载包的路径选择

因为公司这边的服务器无法直接通过url上传文件,所以我之能把AA包打在本地,然后手动上传服务器,加载AA包的路径还是直接写的服务器的url
在这里插入图片描述
这是我打包成功之后,可以看到的相关的AA包文件,我会手动的上传到服务器
在这里插入图片描述

2.dll文件和pdb文件的转换

刚才也说过了,dll文件是无法直接打成AA包的,不受支持的文件类型,所以我们要把dll文件转成byte文件,从而能打成AA包。

1.dll文件转换成byte文件

先上脚本源码:

using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
using UnityEngine.AddressableAssets;

public class DllToBytes
{
    public static string normalPath =Application.dataPath+ "/ILRunTimeAndAddressable/AssetsPackage/HotFixDll";
    public static string normalPathToSave =Application.dataPath+ "/ILRunTimeAndAddressable/AssetsPackage/HotFixDllToBytes";
    [MenuItem("MyMenu/ILRuntime/DllToByte")]
    public static void DllToByte()
    {
        DllToByte(true);
    }
    [MenuItem("MyMenu/ILRuntime/DllToByteChoose")]
    public static void DllToByteChoose()
    {
        DllToByte(false);
    }

    private static void DllToByte(bool autoChoosePath)
    {
        string folderPath,savePath;
        if (autoChoosePath)
        {
            folderPath = normalPath;
        }
        else
        {
            folderPath = EditorUtility.OpenFolderPanel("dll所在的文件夹", Application.dataPath + "/addressable/IlRuntime", string.Empty);
        }

        if (string.IsNullOrEmpty(folderPath))
        {
            return;
        }
        DirectoryInfo directoryInfo=new DirectoryInfo(folderPath);
        FileInfo[] fileInfos = directoryInfo.GetFiles();
        List<FileInfo> listDll=new List<FileInfo>();
        List<FileInfo> listPdb=new List<FileInfo>();

        for (int i = 0; i <fileInfos.Length; i++)
        {
            if (fileInfos[i].Extension==".dll")
            {
                listDll.Add(fileInfos[i]);
            }
            
            else  if (fileInfos[i].Extension==".pdb")
            {
                listPdb.Add(fileInfos[i]);
            }
        }

        if (listDll.Count+listPdb.Count<=0)
        {
            Debug.Log("文件夹下没有dll文件");
        }
        else
        {
            Debug.Log("路径为:"+folderPath);
        }

        if (autoChoosePath)
        {
            savePath = normalPathToSave;
        }
        else
        {
            savePath= EditorUtility.OpenFolderPanel("dll要保存的文件夹", Application.dataPath + "/addressable/IlRuntime", string.Empty);
        }
       
        Debug.Log("-----开始转换dll文件------");
        string path = string.Empty;
        for (int i = 0; i < listDll.Count; i++)
        {
            //$ 符号的作用:等同于string.Format(),不用写占位符了,直接拼起来就可以了
            
            path = $"{savePath}/{Path.GetFileNameWithoutExtension(listDll[i].Name)}_dll_res.bytes";
            Debug.Log(path);
            BytesToFile(path,FileToBytes(listDll[i]));
        }
        Debug.Log("------dll文件转换结束---------");
        
        Debug.Log("-----开始转换pdb文件------");
        for (int i = 0; i < listPdb.Count; i++)
        {
            //$ 符号的作用:等同于string.Format(),不用写占位符了,直接拼起来就可以了
            
            path = $"{savePath}/{Path.GetFileNameWithoutExtension(listPdb[i].Name)}_pdb_res.bytes";
            BytesToFile(path,FileToBytes(listPdb[i]));
        }
        Debug.Log("------pdb文件转换结束---------");
        AssetDatabase.Refresh();

    }
    /// <summary>
    /// file转byte
    /// </summary>
    /// <param name="fileInfo"></param>
    /// <returns></returns>
    private static byte[] FileToBytes(FileInfo fileInfo)
    {
        return File.ReadAllBytes(fileInfo.FullName);
    }
    /// <summary>
    /// byte转文件
    /// </summary>
    /// <param name="path"></param>
    /// <param name="bytes"></param>
    private static void BytesToFile(string path, byte[] bytes)
    {
        Debug.Log($"路径为:{path}\nlength:{bytes.Length}");
        File.WriteAllBytes(path,bytes);
    }
        
}

此脚本放到Editor下面,在unity的工具栏会有对应的选项
在这里插入图片描述
有两个功能,第一个是默认转换固定路径下的dll和pdb文件为byte文件,第二是手动选取文件目录


四、ILRuntime部分

1.一些工具类

1.HotFixSingleton 用于单例,只要继承他就可以了

using UnityEngine;

public class HotFixSingleton<T> where T : class, new()
{
    private static T m_instance;

    public static T Instance
    {
        get
        {
            if (m_instance == null)
            {
                T t = new T();
                if (t is MonoBehaviour)
                {
                    //Debug.LogError("该类不是普通类,需要继承MonoSingleton");
                    return null;
                }
                m_instance = t;
            }
            return m_instance;
        }
    }
}

2.MainBehaviour 实现mono的生命周期

有了这个类,在热更里面也可以用mono的生命周期了,而且性能还是有保障的,用了无GC的反射来做的。
因为AA的加载和实例都是支线程的异步加载,所以我在这个脚本里面加了个资源加载完成的委托,用的也是Action,来降低性能消耗。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using HotFix_Project.UI;

namespace HotFix_Project
{
   public class MainBehaviour:HotFixSingleton<MainBehaviour>
    {
       
        public   void Awake()
        {
            Debug.Log("开始Awake");
            //声明委托
            ILRuntimeWrapper.Instance.TestActionDelegate  = FinishLoad;
            //HotFixEventDispatcher.GameEvent.DispatchEvent(1000);
            UIManager.Instance.LoadUI("MainUIPanel");
            UIManager.Instance.LoadUI("ContentUIPanel");
            //ResMgr.Instance.InstantiateOfAddressables("MainUIPanel");
            //ResMgr.Instance.InstantiateOfAddressables("ContentUIPanel");
        }
        /// <summary>
        /// 添加游戏逻辑
        /// </summary>
        public  void Start()
        {
            Debug.Log("开始Start");
           // HotFixEventDispatcher.GameEvent.DispatchEvent(1001);
        }
        public  void Update()
        {
           // HotFixEventDispatcher.GameEvent.DispatchEvent(1002);
        }
        public  void LateUpdate()
        {
            //Debug.Log("LateUpdate");

        }
        public  void FixedUpdate()
        {
           // Debug.Log("FixedUpdate");
        }
        public  void OnDestroy()
        {
            //HotFixEventDispatcher.GameEvent.DispatchEvent(1003);
        }
        /// <summary>
        /// 实例完prefab的回调
        /// </summary>
        /// <param name="methodName"></param>
        /// <param name="tartget"></param>
        public  void FinishLoad( string methodName,object target)
        {
            Debug.Log($"开始回调——————{target}");
            //HotFixEventDispatcher.GameEvent.DispatchEvent(2000);
            switch (methodName)
            {
                case "MainUIPanel":
                    AllUIModel.Instance.MainUIPanel = (GameObject)target;
                    MainUIPanelView.Instance.InitUI();
                    break;
                case "ContentUIPanel":
                    AllUIModel.Instance.ContentUIPanel = (GameObject)target;
                    ContentUIPanelView.Instance.InitUI();
                    break;
                case "睡觉":
                    ContentUIPanelView.Instance.texture1 = (Sprite)target;
                    break;
                case "1000":
                    ContentUIPanelView.Instance.texture2 = (Sprite)target;
                    break;
                default:
                    break;
            }
        }
    }
}

3.AllUIModel 主要用来存储各种实例

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;

namespace HotFix_Project.UI
{
    /// <summary>
    /// 主要是存放各种UIPanel的实例
    /// </summary>
    class AllUIModel : HotFixSingleton<AllUIModel>
    {
        /// <summary>
        /// 主页面的panel,内容页面的panel
        /// </summary>
        public GameObject MainUIPanel, ContentUIPanel;
        public AllUIModel()
        {
            MainUIPanel = new GameObject();
            ContentUIPanel = new GameObject();
        }
    }
}

2.具体的ui逻辑类

1.ContentUIPanelView, 就是具体到单个的Panel的prefab要实现的ui逻辑

其中一个是主界面的uiPanel,一个是显示界面的uiPanel

ContentUIPanelView

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;

namespace HotFix_Project.UI
{
    class ContentUIPanelView:HotFixSingleton<ContentUIPanelView>
    {
        /// <summary>
        /// 切换按钮,关闭按钮
        /// </summary>
        public Button cutButton, closeButton;

        public Image changeImage;
        public Sprite texture1, texture2;

        public  void InitUI()
        {
            //base.InitUI();
            Debug.Log("开始ContentUIPanel的初始化");
            cutButton= ResMgr.Instance.GetTarget<Button>("CutButton");
            closeButton = ResMgr.Instance.GetTarget<Button>("CloseButton");
            changeImage = ResMgr.Instance.GetTarget<Image>("Picture");
            cutButton.onClick.AddListener(OnCutButton);
            closeButton.onClick.AddListener(OnCloseButton);
            ResMgr.Instance.LoadByAddressable<Sprite>("睡觉");
            ResMgr.Instance.LoadByAddressable<Sprite>("1000");

            AllUIModel.Instance.ContentUIPanel.SetActive(false);
        }
        /// <summary>
        /// 切换按钮事件
        /// </summary>
        private void OnCutButton()
        {
            if (changeImage.sprite==texture1)
            {
                changeImage.sprite = texture2;
            }
            else
            {
                changeImage.sprite = texture1;
            }
            
        }
        /// <summary>
        /// 关闭按钮事件
        /// </summary>
        private void OnCloseButton()
        {
            AllUIModel.Instance.MainUIPanel.SetActive(true);
            AllUIModel.Instance.ContentUIPanel.SetActive(false);
        }
    }
}

2.MainUIPanelView 就是具体到单个的Panel的prefab要实现的ui逻辑

MainUIPanelView

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;

namespace HotFix_Project.UI
{
    class MainUIPanelView:HotFixSingleton<MainUIPanelView>
    {
        public Button showButton;
        public  void InitUI()
        {
           //base.InitUI();
            Debug.Log("开始MainUIPanel的初始化");
            showButton= ResMgr.Instance.GetTarget<Button>("ShowButton");
            showButton.onClick.AddListener(OnShowButton);


            AllUIModel.Instance.MainUIPanel.SetActive(true);
        }
        /// <summary>
        /// 显示按钮的事件
        /// </summary>
        private void OnShowButton()
        {
            Debug.Log("按下了显示按键");
            AllUIModel.Instance.ContentUIPanel.SetActive(true);
            AllUIModel.Instance.MainUIPanel.SetActive(false);
        }
    }
}


五、Addressable和ILRuntime的整合

如果单纯用ILRuntime的话,就是直接执行他生成的dll文件就可以了,现在加上AA打包,就是把dll文件打成AA包放在服务器,工程启动的时候是先从服务器下载打成AA包的dll文件,解析后执行dll文件里面的逻辑,dll文件内的逻辑再从服务器下载指定的AA包并且实例,加载完成后,再执行相关的逻辑,比如一个button,需要确实这个panel的Prebab已经从AA包实例完了,再去添加监听事件。
如果要修改内容,重新运行的流程是这样的:
1.修改内容
2.ILRuntime热更工程重新生成dll文件
3.在主工程里手动把dll文件转换成byte
4.addressable重新打包
5.确认服务器的aa包内容是否更新
6.运行unity


六、工程源码

工程源码等上传hub再来补充链接。


总结

欢迎大佬多多来给萌新指正,欢迎大家来共同探讨。
如果各位看官觉得文章有点点帮助,跪求各位给点个“一键三连”,谢啦~

声明:本博文章若非特殊注明皆为原创原文链接
https://blog.csdn.net/Wrinkle2017/article/details/122668665
————————————————————————————————

💢💢版权声明

版权声明:本博客为非营利性个人原创
所刊登的所有作品的著作权均为本人所拥有
本人保留所有法定权利,违者必究!
对于需要复制、转载、链接和传播博客文章或内容的
请及时和本博主进行联系
对于经本博主明确授权和许可使用文章及内容的
使用时请注明文章或内容出处并注明网址
转载请附上原文出处链接及本声明

  • 7
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值