在使用lua代码开发的过程中,一个非常重要的部分是对C#代码的调用,接下来就结合源码分析理解它的实现:

在lua中,使用诸如以下代码便可以调用C#的代码:

CS.UnityEngine.Debug.Log('hello world')

CS是一个全局的Table,所以CS.UnityEngine可以当做是在一个名为CS的Table中查询名为UnityEngine的值。在调动虚拟机也就是LuaEnv的创建时,会调用以下代码对CS表进行初始化:

DoString(init_xlua, "Init");

初始化代码部分截取如下:

local metatable = {}
local rawget = rawget
local setmetatable = setmetatable
local import_type = xlua.import_type
local import_generic_type = xlua.import_generic_type
local load_assembly = xlua.load_assemblyfunction metatable:__index(key)--获取key为".fqn"的值local fqn = rawget(self, ".fqn")--拼接".fqn"的值和本次调用的keyfqn = ((fqn and fqn .. ".") or "") .. key--查询CS类型local obj = import_type(fqn)if obj == nil then-- It might be an assembly, so we load it too.obj = {[".fqn"] = fqn}setmetatable(obj, metatable)elseif obj == true thenreturn rawget(self, key)end-- Cache this lookuprawset(self, key, obj)return obj
endCS = CS or {}
setmetatable(CS, metatable)

这部分描述了CS表中__index元方法的实现,在我们调用CS表中一个不存在的字段时便会调用这个函数。

我们用CS.UnityEngine.Debug为例来解释这个函数是怎么执行的:

1)首先CS为一个全局的空表,访问CS.UnityEngine由于UnityEngine字段不存在而直接调用到此函数。第一行获取key为".fqn"的值,fqn为空,所以第二行结束后fqn=UnityEngine

2)调用import_type去查询UnityEngine这个类型对应的lua表。

3)对返回值obj进行判断,UnityEngine很明显不是一个类型所以走==nil的分支,创建一个表并且设置".fqn"="UnityEngine",并且将此metatable也设置为这个表的元表

4)将CS表中将obj这个表设置给“UnityEngine”。

5)接下来访问CS.UnityEngine.Debug就相当于从CS.UnityEngine中访问字段Debug,由于没有Debug这个字段所以也会调用到__index这个函数中,这时候fqn就有值了,第二行结束后fqn的值为“UnityEngine.Debug”

6)这时候再通过import_type函数去查询这个类型就有值了,它会走obj==true这个分支,而在这个import_type函数中实际上已经将CS.UnityEngine.Debug的值给设置好了,这时候通过rawget函数来拿到这个lua表返回

以上应该很清晰的解释了CS.XXX的写法实现调用C#代码的过程,而import_type内部是怎么去查找C#类型的,接下来说明:

(注:接下来代码中含有大量关于LuaAPI的部分,想要深入了解的可以查阅《lua程序设计(第4版)》27章之后的内容)

import_type = xlua.import_type。而这个xlua是一个全局table,在C代码中声明,位置为build/xlua.c:

xlua.c:
LUA_API void luaopen_xlua(lua_State *L) {luaL_openlibs(L);#if LUA_VERSION_NUM >= 503luaL_newlib(L, xlualib);lua_setglobal(L, "xlua");
#elseluaL_register(L, "xlua", xlualib);lua_pop(L, 1);
#endif
}

这段代码属于xlua.dll,在创建LuaEnv时会调用,设置一个全局表xlua。同时,luaEnv创建时会注册import_type这个函数,当调用xlua.import_type时会调用到对应的C#委托上:

ObjectTranslator.cs:
public void OpenLib(RealStatePtr L) {if (0 != LuaAPI.xlua_getglobal(L, "xlua")){  throw new Exception("call xlua_getglobal fail!" + LuaAPI.lua_tostring(L, -1));} LuaAPI.xlua_pushasciistring(L, "import_type");LuaAPI.lua_pushstdcallcfunction(L,importTypeFunction);LuaAPI.lua_rawset(L, -3); ...
}

接下来找到这个importTypeFunction委托,会发现最终调用到这个函数:

StaticLuaCallbacks.cs:
public static int ImportType(RealStatePtr L)
{try{ObjectTranslator translator = ObjectTranslatorPool.Instance.Find(L);//需要查询的类名string className = LuaAPI.lua_tostring(L, 1);//查找C#对应的Type(此处还没去查找对应Lua的表)Type type = translator.FindType(className);if (type != null){//这句查找Type对应的lua表if (translator.GetTypeId(L, type) >= 0){LuaAPI.lua_pushboolean(L, true);}else{return LuaAPI.luaL_error(L, "can not load type " + type);}}else{LuaAPI.lua_pushnil(L);}return 1;}
}

通过以上代码会发现,如果没有找到对应的type则直接向lua虚拟栈(lua与其他语言通信的主要组件)中推一个nil,否则则去查找对应的lua表,如果有则推一个true,否则推一个错误码。最终return 1代表的是返回的参数数量为1。总而言之,上述lua代码中xlua.import_type函数返回的obj事实上只可能为true/nil/错误码,而对错误码是没有特殊处理的。

而translator.GetTypeId内部的细节是关键,它包含了xlua实现lua调用C#代码的两种方式:一种是通过生成适配代码,另一种是通过反射来调用

ObjectTranslator.cs
internal int getTypeId(RealStatePtr L, Type type, out bool is_first, LOGLEVEL log_level = LOGLEVEL.WARN)
{int type_id;is_first = false;//查询是否缓存中有Type对应的Lua表,有就直接返回if (!typeIdMap.TryGetValue(type, out type_id)) // no reference{...is_first = true;Type alias_type = null;aliasCfg.TryGetValue(type, out alias_type);//从注册表中检查Type对应的元表LuaAPI.luaL_getmetatable(L, alias_type == null ? type.FullName : alias_type.FullName);//元表为空,走相关注册逻辑if (LuaAPI.lua_isnil(L, -1)) //no meta yet, try to use reflection meta{LuaAPI.lua_pop(L, 1);//此处会去检查是使用反射还是生成适配代码if (TryDelayWrapLoader(L, alias_type == null ? type : alias_type)){LuaAPI.luaL_getmetatable(L, alias_type == null ? type.FullName : alias_type.FullName);}else{throw new Exception("Fatal: can not load metatable of type:" + type);}}//循环依赖,自身依赖自己的class,比如有个自身类型的静态readonly对象。if (typeIdMap.TryGetValue(type, out type_id)){LuaAPI.lua_pop(L, 1);}else{...LuaAPI.lua_pop(L, 1);//缓存type与其对应到lua中的表typeIdMap.Add(type, type_id);}}return type_id;
}public bool TryDelayWrapLoader(RealStatePtr L, Type type)
{if (loaded_types.ContainsKey(type)) return true;loaded_types.Add(type, true);LuaAPI.luaL_newmetatable(L, type.FullName); LuaAPI.lua_pop(L, 1);Action<RealStatePtr> loader;int top = LuaAPI.lua_gettop(L);.//这个delayWrap字典在Xlua生成的代码中(在XLua_Gen_Initer_Register__类//实例化时)将每一个类型的wrap注册在里面if (delayWrap.TryGetValue(type, out loader)){delayWrap.Remove(type);//将类方法,字段,成员等注册loader(L);}//这里就是反射的逻辑了else{...//用反射将类方法,字段,成员等注册Utils.ReflectionWrap(L, type, privateAccessibleFlags.Contains(type));...}......return true;
}

首先来看如果有生成适配代码是怎么执行的,也就是上面代码中的loader(L)的执行。以Vector3为例,实际上执行的是以下代码:

public static void __Register(RealStatePtr L)
{ObjectTranslator translator = ObjectTranslatorPool.Instance.Find(L);System.Type type = typeof(UnityEngine.Vector3);Utils.BeginObjectRegister(type, L, translator, 6, 6, 6, 3);//这边注册一些+/-/==等操作符相关的方法Utils.RegisterFunc(L, Utils.OBJ_META_IDX, "__add", __AddMeta);...Utils.RegisterFunc(L, Utils.OBJ_META_IDX, "__eq", __EqMeta);//这边注册的是成员函数                  Utils.RegisterFunc(L, Utils.METHOD_IDX, "Set", _m_Set);Utils.RegisterFunc(L, Utils.METHOD_IDX, "GetHashCode", _m_GetHashCode);...Utils.RegisterFunc(L, Utils.METHOD_IDX, "Equals", _m_Equals);Utils.RegisterFunc(L, Utils.METHOD_IDX, "ToString", _m_ToString);//这边注册的是成员变量的get                       Utils.RegisterFunc(L, Utils.GETTER_IDX, "normalized", _g_get_normalized);...Utils.RegisterFunc(L, Utils.GETTER_IDX, "y", _g_get_y);Utils.RegisterFunc(L, Utils.GETTER_IDX, "z", _g_get_z);//这边注册的是成员变量的set          Utils.RegisterFunc(L, Utils.SETTER_IDX, "x", _s_set_x);Utils.RegisterFunc(L, Utils.SETTER_IDX, "y", _s_set_y);Utils.RegisterFunc(L, Utils.SETTER_IDX, "z", _s_set_z);Utils.EndObjectRegister(type, L, translator, __CSIndexer, __NewIndexer,null, null, null);Utils.BeginClassRegister(type, L, __CreateInstance, 26, 10, 0);//这边注册的是静态函数 Utils.RegisterFunc(L, Utils.CLS_IDX, "Slerp", _m_Slerp_xlua_st_);...  Utils.RegisterObject(L, translator, Utils.CLS_IDX, "kEpsilonNormalSqrt", UnityEngine.Vector3.kEpsionNormalSqrt);//这边注册的是静态变量的get和set(Vector3没有静态变量的set所以这里没有)         Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "zero", _g_get_zero);Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "one", _g_get_one);Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "forward", _g_get_forward);Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "back", _g_get_back);Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "up", _g_get_up);Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "down", _g_get_down);Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "left", _g_get_left);Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "right", _g_get_right);Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "positiveInfinity", _g_get_positiveInfinity);Utils.RegisterFunc(L, Utils.CLS_GETTER_IDX, "negativeInfinity", _g_get_negativeInfinity);Utils.EndClassRegister(type, L, translator);
}

从上代码可看出主要分两部分:静态和对象。

首先从静态部分说起:

Utils.BeginClassRegister函数中在创建了4个lua表,分别是cls_table、cls_metatable、cls_getter_table、cls_setter_table放在栈中-4、-3、-2、-1的位置(-1指的是栈顶,-2是栈顶向下一个的位置,以此类推,栈底是1)。其中cls_table就是此类型对应的lua表,cls_metatable是这个lua表的元表,getter表和Setter表是后面辅助实现__index和__newindex元方法的。

而Utils.RegisterFunc则在这些表中注册上对应的方法:

Utils.cs
public static void RegisterFunc(RealStatePtr L, int idx, string name, LuaCSFunction func)
{idx = abs_idx(LuaAPI.lua_gettop(L), idx);LuaAPI.xlua_pushasciistring(L, name);LuaAPI.lua_pushstdcallcfunction(L, func);LuaAPI.lua_rawset(L, idx);
}

以代码“Utils.RegisterFunc(L, Utils.CLS_IDX, "Slerp", _m_Slerp_xlua_st_);”为例,相当于在cls_table中注册了Slerp字段,当调用Vector3.Slerp时就会调用到 _m_Slerp_xlua_st_函数上来。

当类型中所有东西都注册完了以后,在函数Utils.EndClassRegister中将__index元方法和__newindex元方法实现好并设置在元表中:

public static void EndClassRegister(Type type, RealStatePtr L, ObjectTranslator translator)
{//获取栈顶索引以及4个lua表的索引int top = LuaAPI.lua_gettop(L);int cls_idx = abs_idx(top, CLS_IDX);int cls_getter_idx = abs_idx(top, CLS_GETTER_IDX);int cls_setter_idx = abs_idx(top, CLS_SETTER_IDX);int cls_meta_idx = abs_idx(top, CLS_META_IDX);//begin cls indexLuaAPI.xlua_pushasciistring(L, "__index");//向栈中压入“__index”LuaAPI.lua_pushvalue(L, cls_getter_idx);//压入cls_get_tableLuaAPI.lua_pushvalue(L, cls_idx);//压入cls_tabletranslator.Push(L, type.BaseType());//压入BaseType的地址//压入存放__index元方法的表的Key,这个表存放了所有类型的__index元方法LuaAPI.xlua_pushasciistring(L, LuaClassIndexsFieldName);//从注册表(可以看作是存放全局变量的地方)中取出这个__index集合表LuaAPI.lua_rawget(L, LuaIndexes.LUA_REGISTRYINDEX);//这里会创建一个闭包函数cls_indexer并压入栈,关联并弹出上面压入的除了"__index"的值作为上值//这个闭包函数中实现了关于lua中__index元方法的功能//这一步执行过后栈中为:"__index"|cls_indexerLuaAPI.gen_cls_indexer(L);//压入LuaClassIndexsFieldNameLuaAPI.xlua_pushasciistring(L, LuaClassIndexsFieldName);//弹出key,压入__index集合表LuaAPI.lua_rawget(L, LuaIndexes.LUA_REGISTRYINDEX);//store in lua indexs function tables//压入当前类型translator.Push(L, type);//此时-3的位置是cls_indexer,这里意思是复制一份压入栈顶LuaAPI.lua_pushvalue(L, -3);//将这个cls_indexer存入__index的集合表,LuaAPI.lua_rawset(L, -3);//__index将集合表弹出LuaAPI.lua_pop(L, 1);//设置cls_metatable[__index] = cls_indexer,并弹出键和值LuaAPI.lua_rawset(L, cls_meta_idx);//end cls index//begin cls newindexLuaAPI.xlua_pushasciistring(L, "__newindex");//__nexindex与__index步骤一样,不再叙述...//设置cls_metatable[__newindex] = cls_newindexerLuaAPI.lua_rawset(L, cls_meta_idx);//end cls newindexLuaAPI.lua_pop(L, 4);
}

对象和静态实现的代码基本上一致,在Utils.BeginObjectRegister创建4个表:obj_meta,obj_method,obj_get,obj_set。之后在Utils.EndObjectRegister中将元表中设置并实现__index和__newindex,当每次使用时根据类型去全局变量中查找这个元表,完成操作映射。

当某个类型没有被生成适配代码时,实际上也是可以被lua访问到的,这时候xlua走的是一个反射的机制,反射的分支可以从ObjectTranslator.TryDelayWrapLoader看到:

public bool TryDelayWrapLoader(RealStatePtr L, Type type)
{...if (delayWrap.TryGetValue(type, out loader)){...}//当Type没有适配代码时会走这个else分支else{...//用反射将类方法,字段,成员等注册Utils.ReflectionWrap(L, type, privateAccessibleFlags.Contains(type));...}......return true;
}

Utils.ReflectionWrap这个函数中实际上是将上面适配代码相关注册逻辑都包含在里面了,对于类型中的字段处理:

Utils.cs
static void makeReflectionWrap(RealStatePtr L, Type type, int cls_field, int cls_getter, int cls_setter,int obj_field, int obj_getter, int obj_setter, int obj_meta, out LuaCSFunction item_getter, out LuaCSFunction item_setter, BindingFlags access)
{...   for (int i = 0; i < fields.Length; ++i){...if (field.IsStatic && (field.IsInitOnly || field.IsLiteral)){//对于静态字段,直接将字段名=value保存在cls_field表(等同于之前的cls_table)中LuaAPI.xlua_pushasciistring(L, fieldName);translator.PushAny(L, field.GetValue(null));LuaAPI.lua_rawset(L, cls_field);}else{//成员字段则做一个闭包函数保存在表中,//映射于C#端的StaticLuaCallbacks.FixCSFunctionWraper这个委托LuaAPI.xlua_pushasciistring(L, fieldName);translator.PushFixCSFunction(L, genFieldGetter(type, field));LuaAPI.lua_rawset(L, field.IsStatic ? cls_getter : obj_getter);LuaAPI.xlua_pushasciistring(L, fieldName);translator.PushFixCSFunction(L, genFieldSetter(type, field));LuaAPI.lua_rawset(L, field.IsStatic ? cls_setter : obj_setter);}}...//事件映射到FixCSFunctionWraper委托EventInfo[] events = type.GetEvents(flag);for (int i = 0; i < events.Length; ++i){EventInfo eventInfo = events[i];LuaAPI.xlua_pushasciistring(L, eventInfo.Name);translator.PushFixCSFunction(L, translator.methodWrapsCache.GetEventWrap(type, eventInfo.Name));bool is_static = (eventInfo.GetAddMethod(true) != null) ? eventInfo.GetAddMethod(true).IsStatic : eventInfo.GetRemoveMethod(true).IsStatic;LuaAPI.lua_rawset(L, is_static ? cls_field : obj_field);}...//方法映射到FixCSFunctionWraper委托foreach (var kv in pending_methods){if (kv.Key.Name.StartsWith("op_")) // 操作符{LuaAPI.xlua_pushasciistring(L, InternalGlobals.supportOp[kv.Key.Name]);translator.PushFixCSFunction(L,new LuaCSFunction(translator.methodWrapsCache._GenMethodWrap(type, kv.Key.Name, kv.Value.ToArray()).Call));LuaAPI.lua_rawset(L, obj_meta);}else{LuaAPI.xlua_pushasciistring(L, kv.Key.Name);translator.PushFixCSFunction(L,new LuaCSFunction(translator.methodWrapsCache._GenMethodWrap(type, kv.Key.Name, kv.Value.ToArray()).Call));LuaAPI.lua_rawset(L, kv.Key.IsStatic ? cls_field : obj_field);}}
}

另外还有属性的getter、setter同样也映射到FixCSFunctionWraper委托,代码都在Utils.ReflectionWrap这个函数中,但分散在不同的地方,就不贴了。关于这个PushFixCSFunction中是如何映射的:

internal void PushFixCSFunction(RealStatePtr L, LuaCSFunction func)
{if (func == null){LuaAPI.lua_pushnil(L);}else{//这里压入一个索引LuaAPI.xlua_pushinteger(L, fix_cs_functions.Count);//将函数缓存在fix_cs_functions中fix_cs_functions.Add(func);//创建一个闭包函数并压入栈,将上面的索引作为upvalueLuaAPI.lua_pushstdcallcfunction(L, metaFunctions.FixCSFunctionWraper, 1);}
}

每当我们通过反射调用时,最终都会调用到FixCSFunctionWraper这个委托中:

StaticLuaCallbacks.cs
static int FixCSFunction(RealStatePtr L)
{try{ObjectTranslator translator = ObjectTranslatorPool.Instance.Find(L);int idx = LuaAPI.xlua_tointeger(L, LuaAPI.xlua_upvalueindex(1));LuaCSFunction func = (LuaCSFunction)translator.GetFixCSFunction(idx);return func(L);}catch (Exception e){return LuaAPI.luaL_error(L, "c# exception in FixCSFunction:" + e);}
}

从上面代码可以看到,每次调用时会取出upvalue中的索引,然后从fix_cs_functions列表中取出函数进行调用。

至此,整个调用就已经讲完了。从上可以看出,每个类型的第一次调用会进行执行一系列注册逻辑,这部分逻辑是有大量的GC Alloc的,在我们实际开发中,应该尽量将这些逻辑提前在性能不吃紧的位置执行。

另外在lua中使用到的CS类型也应该保证去生成适配代码,否则将会走反射的逻辑,而反射的效率不高并且在反射API的调用中有少量GC产生。

XLua源码学习:Lua中调用CS相关推荐

  1. ETCD 源码学习--Raft 中 progress 的 inFlight 实现(九)

    首先需要搞清什么是 inFlight,inFlight 在 Raft 中存储的是已发送给 Follower 的 MsgApp 消息,但没有收到 MsgAppResp 的消息 Index  值.简单的说 ...

  2. Java多线程之JUC包:Semaphore源码学习笔记

    若有不正之处请多多谅解,并欢迎批评指正. 请尊重作者劳动成果,转载请标明原文链接: http://www.cnblogs.com/go2sea/p/5625536.html Semaphore是JUC ...

  3. mutations vuex 调用_Vuex源码学习(六)action和mutation如何被调用的(前置准备篇)...

    前言 Vuex源码系列不知不觉已经到了第六篇.前置的五篇分别如下: 长篇连载:Vuex源码学习(一)功能梳理 长篇连载:Vuex源码学习(二)脉络梳理 作为一个Web前端,你知道Vuex的instal ...

  4. action mutation 调用_Vuex源码学习(六)action和mutation如何被调用的(前置准备篇)...

    module与moduleCollection你一定要会啊!Vuex源码学习(五)加工后的module 在组件中使用vuex的dispatch和commit的时候,我们只要把action.mutati ...

  5. Struts2源码学习(一)——Struts2中的XWork容器

    接下来记录几篇学习Struts2源码的文章,希望能温故而知新. 目录: 1, 为什么引入容器 2,容器的定义 3,对象创建分析 4,依赖注入分析 5,对象创建和依赖注入的实现 首先,了解为什么框架要引 ...

  6. Java 中 Integer 源码学习之缓存池了解

    Java 中 Integer 源码学习之缓存池了解 面试题 new Integer(123) 与 Integer.valueOf(123) 的区别? new Integer(123) 每次都会新建一个 ...

  7. postgresql源码学习(57)—— pg中的四种动态库加载方法

    一. 基础知识 1. 什么是库 库其实就是一些通用代码,可以在程序中重复使用,比如一些数学函数,可以不需要自己编写,直接调用相关函数即可实现,避免重复造轮子. 在linux中,支持两种类型的库: 1. ...

  8. ASP.NET Core MVC 源码学习:Routing 路由

    前言 最近打算抽时间看一下 ASP.NET Core MVC 的源码,特此把自己学习到的内容记录下来,也算是做个笔记吧. 路由作为 MVC 的基本部分,所以在学习 MVC 的其他源码之前还是先学习一下 ...

  9. MVC系列——MVC源码学习:打造自己的MVC框架(一:核心原理)(转)

    阅读目录 一.MVC原理解析 1.MVC原理 二.HttpHandler 1.HttpHandler.IHttpHandler.MvcHandler的说明 2.IHttpHandler解析 3.Mvc ...

最新文章

  1. 查看磁盤使用情况linux,在Linux系统下安装Filelight来查看磁盘使用情况
  2. bzoj1874: [BeiJing2009 WinterCamp]取石子游戏
  3. live555 源码分析:MediaSever
  4. Spring-JDBC通用Dao
  5. git ssh拉取代码_阿里云搭建git服务器
  6. 哇塞!野生海鲜竟然从渔港直送到你家!喜欢吃海鲜的有福了!
  7. 飞鸽传书也在2010年免费发布了
  8. C# XML操作之读取XML数据
  9. php提交失败阻止提交数据,php – 在刷新浏览器时阻止重新提交提交
  10. ubuntu mate 开机自动启动ssh服务
  11. Codeforces 464E. The Classic Problem
  12. Android开发案例 点击按钮出现 简易的消息提示框
  13. c#WPF 扫雷游戏
  14. python3.6 numpy下载_numpy下载安装 NumPy MKL v1.13.1 cp36 for Python3.6 官方安装版 64位 下载-脚本之家...
  15. 视频 | 直升机如何转弯,为什么能悬停在空中,它的飞行原理是什么?
  16. word 此文件来自其它计算机,问题解决: 此文件来自其他计算机,可能被阻止以帮助保护该计算机/WORD在试图打开文件时遇到错误……...
  17. android 视频通话框架,Android基于腾讯云实时音视频仿微信视频通话最小化悬浮
  18. 从off-heap到Azul's Zing(JVM)
  19. html在表格中建立表单
  20. 武汉市公交老年卡在什么地方可以年检

热门文章

  1. FTPC--KepWare--OPC--PLC读写
  2. SecureCRT的下载与注册
  3. 【NOIP2017提高组】奶酪
  4. Windows 10磁盘占用100%解决办法
  5. import pandas as pd# 读取两个 Excel 文件df1 = pd.read_excel(file1.xlsx)df2 = pd.read_excel(file2.xlsx)...
  6. 大模型很火,阿里巴巴达摩院招NLP算法春招实习生!
  7. chrome 出现:Your connection is not private NET::ERR_CERT_WEAK_SIGNATURE_ALGORITHM
  8. 全国各大城市的经纬度表,留着以后做查询库用
  9. 10张PPT干货,教你写出一流的文案
  10. txt文件打开和保存