简介

Shadow是最近腾讯开源的一款插件化框架。原理是使用宿主代理的方式实现组件的生命周期。目前的插件化框架,大部分都是使用hook系统的方式来做的。使用代理的基本上没有成体系的框架,只是一些小demo,Shadow框架的开源,在系统api 控制越来越严格的趋势下,算是一个新的方向。

框架对比

Shadow 主要具有以下特点:

  • 复用独立安装 App 的源码:插件 App 的源码原本就是可以正常安装运行的。
  • 零反射无 Hack 实现插件技术:从理论上就已经确定无需对任何系统做兼容开发,更无任何隐藏 API 调用,和 Google 限制非公开 SDK 接口访问的策略完全不冲突。
  • 全动态插件框架:一次性实现完美的插件框架很难,但 Shadow 将这些实现全部动态化起来,使插件框架的代码成为了插件的一部分。插件的迭代不再受宿主打包了旧版本插件框架所限制。
  • 宿主增量极小:得益于全动态实现,真正合入宿主程序的代码量极小(15KB,160 方法数左右)。
  • Kotlin 实现:core.loader,core.transform 核心代码完全用 Kotlin 实现,代码简洁易维护。

编译与开发环境

环境准备

第一次 clone Shadow 的代码到本地后,建议先在命令行编译一次。

  • 在编译前,必须设置 ANDROID_HOME 环境变量。
  • 在编译时,必须使用 gradlew 脚本,以保证采用了项目配置的 Gradle 版本。

在命令行测试编译时可以执行这个任务:

./gradlew build

如果没有出错,再尝试用 Android Studio 打开工程。

  • 必须使用 3.4 或更高版本的 Android Studio 打开工程。(业务插件开发时没有限制)
  • 必须关闭 Android Studio 的 Instant Run 功能。

然后就可以在 IDE 中选择 sample-host 模块直接运行了。

Shadow 的所有代码都位于 projects 目录下的 3 个目录,分别是:

  • sdk 包含 SDK 的所有代码
  • test 包含 SDK 的自动化测试代码
  • sample 包含演示代码

其中 sample 应该是大家体验 Shadow 的最佳环境。 详见 sample 目录中的 README 介绍。

注意事项:

  • 1、Shadow是跨进程的,插件运行在插件进程,通过Binder机制通信,所以不了解Binder的,建议提前熟悉一下,否则看着会比较绕。
  • 2、Shadow的宿主和业务插件之间还有一层中间层,中间层也是以插件的形式加载,同时可以升级,有较强的灵活性。
  • 3、插件里写一个页面,比如继承自Activity,我们可以正常写,但是在编译期会修改继承关系,将其父类改为ShadowActivity,ShadowActivity实际上不是一个Activity,他持有HostActivity的代理对象,依赖此完成生命周期的回调。

这个操作是靠修改字节码实现的,自定义gradle脚本,通过javassist或者asm都可以实现,不再赘述。

以上是Shadow插件化的简单介绍;想要更多深入学习Android知识可以参考这份电子册《Android核心进阶技术手册》点击查看获取方式,免费且好用系列

源码分析:

Shadow源码较多,我们只分析一下插件Activity是如何启动及运行的。

可以先看一下打包出来的apk的结构

我的理解pluginmanager.apk loader.apk runtime.apk是中间层

config.json 是发版信息,主要用于检查更新,其中的uuid即为当前版本的唯一标示

HostApplication的onCreate方法会有一些初始化的工作,主要是把asset目录下的插件复制到指定目录,还有runtime插件的状态恢复,非核心流程,不再详述。

我们直接看启动插件的逻辑,很容易就找到加载插件的缺省页PluginLoadActivity,只有一个startPlugin方法:

public void startPlugin() {PluginHelper.getInstance().singlePool.execute(new Runnable() {@Overridepublic void run() {// 方法名虽然叫loadPluginManager,实际上并没有真正安装manager插件,// 只是将插件路径包装成FixedPathPmUpdater,作为构造函数的参数,创建一个DynamicPluginManager保存在Application中HostApplication.getApp().loadPluginManager(PluginHelper.getInstance().pluginManagerFile);Bundle bundle = new Bundle();//插件的安装路径bundle.putString(Constant.KEY_PLUGIN_ZIP_PATH, PluginHelper.getInstance().pluginZipFile.getAbsolutePath());//当前值是:sample-plugin-appbundle.putString(Constant.KEY_PLUGIN_PART_KEY, getIntent().getStringExtra(Constant.KEY_PLUGIN_PART_KEY));//要启动的插件中的Activity路径 com.tencent.shadow.sample.plugin.app.lib.gallery.splash.SplashActivitybundle.putString(Constant.KEY_ACTIVITY_CLASSNAME, getIntent().getStringExtra(Constant.KEY_ACTIVITY_CLASSNAME));//EnterCallback主要是用于处理插件加载过程中的过度状态HostApplication.getApp().getPluginManager().enter(PluginLoadActivity.this, Constant.FROM_ID_START_ACTIVITY, bundle, new EnterCallback() {@Overridepublic void onShowLoadingView(final View view) {mHandler.post(new Runnable() {@Overridepublic void run() {mViewGroup.addView(view);}});}@Overridepublic void onCloseLoadingView() {finish();}@Overridepublic void onEnterComplete() {}});}});}

懒的长篇大论,相关逻辑已经写在注释里,会执行到DynamicPluginManager的enter方法:

public void enter(Context context, long fromId, Bundle bundle, EnterCallback callback) {if (mLogger.isInfoEnabled()) {mLogger.info("enter fromId:" + fromId + " callback:" + callback);}//动态管理插件的更新逻辑updateManagerImpl(context);//mManagerImpl的类型是SamplePluginManagermManagerImpl.enter(context, fromId, bundle, callback);mUpdater.update();}

mManagerImpl是一个接口,上面的代码其真实实例是SamplePluginManager,updateManagerImpl方法会安装pluginmanager.apk插件,同时通过反射创建一个SamplePluginManager实例,也就是上面的mManagerImpl,同时支持pluginmanager.apk插件的更新逻辑。

所以进入SamplePluginManager的enter->onStartActivity,代码逻辑比较简单,没什么可说的,需要注意一点是会启动一个线程,去加载zip包下的几个插件(runtime、loader、业务插件),而后会调用到其父类FastPluginManager的startPluginActivity方法:

public void startPluginActivity(Context context, InstalledPlugin installedPlugin, String partKey, Intent pluginIntent) throws RemoteException, TimeoutException, FailedException {Intent intent = convertActivityIntent(installedPlugin, partKey, pluginIntent);if (!(context instanceof Activity)) {intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);}//最终启动的是com.tencent.shadow.sample.plugin.runtime.PluginDefaultProxyActivity//PluginDefaultProxyActivity 在宿主manifest中有注册context.startActivity(intent);}

核心流程就在convertActivityIntent里,从命名就可以看出来,最终会把我们要启动的插件Activity,映射成一个在Manifest里注册的真实Activity,也就是注释中标注的PluginDefaultProxyActivity。

可以回看一下上文“思考”中的内容,即为Shadow第一次使用插件的主要流程,convertActivityIntent的代码如下:

public Intent convertActivityIntent(InstalledPlugin installedPlugin, String partKey, Intent pluginIntent) throws RemoteException, TimeoutException, FailedException {//这个partKey的真实值是"sample-plugin-app"loadPlugin(installedPlugin.UUID, partKey);Map map = mPluginLoader.getLoadedPlugin();Boolean isCall = (Boolean) map.get(partKey);if (isCall == null || !isCall) {//其持有的是PluginLoaderBinder的引用//这里又是一次跨进程通信mPluginLoader.callApplicationOnCreate(partKey);}return mPluginLoader.convertActivityIntent(pluginIntent);}

loadPlugin:先安装中间层插件再安装业务插件,当然如果已安装,直接跳过

mPluginLoader:是一个比较关键的变量,具体他是什么初始化的,下面会具体分析

后续的代码执行逻辑可自行看源码,首先会执行loadPluginLoaderAndRuntime方法,这个方法里会初始化插件进程的服务,同时将插件进程的binder对象赋值给mPpsController:

private void loadPluginLoaderAndRuntime(String uuid, String partKey) throws RemoteException, TimeoutException, FailedException {if (mPpsController == null) {//partKey是启动插件的时候在PluginLoadActivity中赋值//getPluginProcessServiceName 获取插件进程服务的名字//bindPluginProcessService启动插件进程服务 由此可见,shadow宿主和插件的信息传递是进程间通信的过程bindPluginProcessService(getPluginProcessServiceName(partKey));//等待链接超时时间waitServiceConnected(10, TimeUnit.SECONDS);}loadRunTime(uuid);loadPluginLoader(uuid);}....../*** 启动PluginProcessService** @param serviceName 注册在宿主中的插件进程管理service完整名字*/public final void bindPluginProcessService(final String serviceName) {if (mServiceConnecting.get()) {if (mLogger.isInfoEnabled()) {mLogger.info("pps service connecting");}return;}if (mLogger.isInfoEnabled()) {mLogger.info("bindPluginProcessService " + serviceName);}mConnectCountDownLatch.set(new CountDownLatch(1));mServiceConnecting.set(true);//CountDownLatch是一个同步工具,协调多个线程之间的同步//可以看下这篇文章 https://www.cnblogs.com/Lee_xy_z/p/10470181.htmlfinal CountDownLatch startBindingLatch = new CountDownLatch(1);final boolean[] asyncResult = new boolean[1];//从onStartActivity方法可知,当前线程并不是UI线程mUiHandler.post(new Runnable() {@Overridepublic void run() {Intent intent = new Intent();//serviceName的值是com.tencent.shadow.sample.host.PluginProcessPPSintent.setComponent(new ComponentName(mHostContext, serviceName));boolean binding = mHostContext.bindService(intent, new ServiceConnection() {@Overridepublic void onServiceConnected(ComponentName name, IBinder service) {//service对应的是PluginProcessService中的mPpsControllerBinderif (mLogger.isInfoEnabled()) {mLogger.info("onServiceConnected connectCountDownLatch:" + mConnectCountDownLatch);}mServiceConnecting.set(false);mPpsController = PluginProcessService.wrapBinder(service);try {//跨进程执行PluginProcessService的setUuidManager方法//UuidManagerBinder内部封装了三个方法,可以让插件进程拿到loader、runtime及指定其他业务插件的相关信息mPpsController.setUuidManager(new UuidManagerBinder(PluginManagerThatUseDynamicLoader.this));} catch (DeadObjectException e) {if (mLogger.isErrorEnabled()) {mLogger.error("onServiceConnected RemoteException:" + e);}} catch (RemoteException e) {if (e.getClass().getSimpleName().equals("TransactionTooLargeException")) {if (mLogger.isErrorEnabled()) {mLogger.error("onServiceConnected TransactionTooLargeException:" + e);}} else {throw new RuntimeException(e);}}try {//第一次拿到的是一个nullIBinder iBinder = mPpsController.getPluginLoader();if (iBinder != null) {mPluginLoader = new BinderPluginLoader(iBinder);}} catch (RemoteException ignored) {if (mLogger.isErrorEnabled()) {mLogger.error("onServiceConnected mPpsController getPluginLoader:", ignored);}}mConnectCountDownLatch.get().countDown();if (mLogger.isInfoEnabled()) {mLogger.info("onServiceConnected countDown:" + mConnectCountDownLatch);}}@Overridepublic void onServiceDisconnected(ComponentName name) {if (mLogger.isInfoEnabled()) {mLogger.info("onServiceDisconnected");}mServiceConnecting.set(false);mPpsController = null;mPluginLoader = null;}}, BIND_AUTO_CREATE);asyncResult[0] = binding;startBindingLatch.countDown();}});try {//当前线程会最多等待10s,startBindingLatch的线程计数为0之前,当前线程会处在中断状态startBindingLatch.await(10, TimeUnit.SECONDS);if (!asyncResult[0]) {throw new IllegalArgumentException("无法绑定PPS:" + serviceName);}} catch (InterruptedException e) {throw new RuntimeException(e);}}

上文说过,整个流程是运行在子线程,所以启动服务要post到UI线程

后续执行的loadRunTime(uuid);loadPluginLoader(uuid);方法即为启动中间层插件的逻辑,大同小异,只分析loadPluginLoader的执行逻辑,因为要解释关键变量mPluginLoader是怎么来的。

public final void loadPluginLoader(String uuid) throws RemoteException, FailedException {if (mLogger.isInfoEnabled()) {mLogger.info("loadPluginLoader mPluginLoader:" + mPluginLoader);}if (mPluginLoader == null) {PpsStatus ppsStatus = mPpsController.getPpsStatus();if (!ppsStatus.loaderLoaded) {//动态加载sample-loader-debug.apk此插件 在插件进程创建了PluginLoaderBinder的实体mPpsController.loadPluginLoader(uuid);}//拿到PluginLoaderBinder的引用IBinder iBinder = mPpsController.getPluginLoader();mPluginLoader = new BinderPluginLoader(iBinder);}}

PpsStatus:只是一个状态bean,唯一作用就是保存插件的安装状态

mPpsController:怎么来的上文已经说过,所以他所调用的方法的具体实现,都是插件进程Service里,即PluginProcessService

mPpsController.loadPluginLoader方法,即为安装loader插件,具体不再分析,可以自行查看Shadow源码

PluginProcessService的loadPluginLoader方法调用,有个关键点要注意:

void loadPluginLoader(String uuid) throws FailedException {...try {...//pluginLoader类型:PluginLoaderBinder//pluginLoader持有DynamicPluginLoader的对象 封装了一系列插件运行的方法PluginLoaderImpl pluginLoader = new LoaderImplLoader().load(installedApk, uuid, getApplicationContext());pluginLoader.setUuidManager(mUuidManager);mPluginLoader = pluginLoader;} catch (RuntimeException e) {...} catch (Exception e) {...}}

上文中提到,第一次启动插件服务的时候mPluginLoader是null,他的初始化就是在这里,反射创建了一个PluginLoaderBinder对象,也就是mPluginLoader。但是真正干活的是其持有的DynamicPluginLoader对象。具体可以看一下com.tencent.shadow.dynamic.loader.impl.LoaderFactoryImpl类

不要忘了这是跨进程的,所以要这样封装,mPluginLoader也是一个binder对象。

再回到FastPluginManager的loadPlugin方法

中间层插件已处理完,那就到了业务插件,会调用mPluginLoader.getLoadedPlugin(),会返回已安装的插件信息,这个方法的具体实现,从上文分析可知,是在DynamicPluginLoader里。如果要加载的插件没有安装,会调用mPluginLoader.loadPlugin(partKey);安装指定插件。

后续的插件安装逻辑直接看源码吧,相信大家都能看懂,会调到ShadowPluginLoader的loadPlugin方法。

再回到convertActivityIntent方法

如果插件是第一次启动,那么会调用mPluginLoader.callApplicationOnCreate(partKey);

mPluginLoader是谁已经说了很多次,不再强调。这个方法会初始化插件的contentprovider以及broadcastreceiver

我们直接看mPluginLoader.convertActivityIntent(pluginIntent),一连串的方法调用连,最终会调用到ComponentManager类的方法:

/*** 调用前必须先调用isPluginComponent判断Intent确实一个插件内的组件*/private fun Intent.toActivityContainerIntent(): Intent {val bundleForPluginLoader = Bundle()val pluginComponentInfo = pluginComponentInfoMap[component]!!bundleForPluginLoader.putParcelable(CM_ACTIVITY_INFO_KEY, pluginComponentInfo)return toContainerIntent(bundleForPluginLoader)}

其实很好理解,这里就是将插件Activity映射到我们注册在宿主的Activity,同时将映射关系以及一些必要的数据传递。

在demo里最终映射的Activity是com.tencent.shadow.sample.plugin.runtime.PluginDefaultProxyActivity

这是一个真实的Activity,可以正常启动。其主要逻辑都在父类PluginContainerActivity中。

先看PluginContainerActivity的初始化方法:

    HostActivityDelegate hostActivityDelegate;public PluginContainerActivity() {HostActivityDelegate delegate;DelegateProvider delegateProvider = DelegateProviderHolder.getDelegateProvider();if (delegateProvider != null) {delegate = delegateProvider.getHostActivityDelegate(this.getClass());delegate.setDelegator(this);} else {Log.e(TAG, "PluginContainerActivity: DelegateProviderHolder没有初始化");delegate = null;}hostActivityDelegate = delegate;}

hostActivityDelegate:看命名就知道,这是宿主Activity的代理类,我猜应该是给插件Activity使用的,你们觉得呢?

我们来看一下hostActivityDelegate到底是什么:

override fun getHostActivityDelegate(aClass: Class<out HostActivityDelegator>): HostActivityDelegate {return ShadowActivityDelegate(this)}

回到PluginContainerActivity,以onCreate方法为例:

 @Overridefinal protected void onCreate(Bundle savedInstanceState) {...if (hostActivityDelegate != null) {hostActivityDelegate.onCreate(savedInstanceState);} else {...}}

这里会调用hostActivityDelegate的onCreate,也就是ShadowActivityDelegate类的onCreate方法:

/*** com.tencent.shadow.core.loader.delegates.ShadowActivityDelegate*/override fun onCreate(savedInstanceState: Bundle?) {...try {val aClass = mPluginClassLoader.loadClass(pluginActivityClassName)val pluginActivity = PluginActivity::class.java.cast(aClass.newInstance())initPluginActivity(pluginActivity)mPluginActivity = pluginActivity...pluginActivity.onCreate(pluginSavedInstanceState)mPluginActivityCreated = true} catch (e: Exception) {throw RuntimeException(e)}}private fun initPluginActivity(pluginActivity: PluginActivity) {pluginActivity.setHostActivityDelegator(mHostActivityDelegator)pluginActivity.setPluginResources(mPluginResources)pluginActivity.setHostContextAsBase(mHostActivityDelegator.hostActivity as Context)pluginActivity.setPluginClassLoader(mPluginClassLoader)pluginActivity.setPluginComponentLauncher(mComponentManager)pluginActivity.setPluginApplication(mPluginApplication)pluginActivity.setShadowApplication(mPluginApplication)pluginActivity.applicationInfo = mPluginApplication.applicationInfopluginActivity.setBusinessName(mBusinessName)pluginActivity.setPluginPartKey(mPartKey)pluginActivity.remoteViewCreatorProvider = mRemoteViewCreatorProvider}

省略掉一些常规代码

val aClass = mPluginClassLoader.loadClass(pluginActivityClassName)

pluginActivityClassName:我们要启动的插件Activity的类路径即为SplashActivity

反射实例化保存在mPluginActivity,用于调用插件Activity的生命周期等系统方法

那么插件Activity要调用super方法,比如onCreate的super方法怎么办呢?

在initPluginActivity方法中会将mHostActivityDelegator 传递给插件activity使用:

pluginActivity.setHostActivityDelegator(mHostActivityDelegator)

本文最开始说过,插件Activity会在编译期修改其继承关系为ShadowActivity,ShadowActivity继承自PluginActivity:

public abstract class PluginActivity extends ShadowContext implements Window.Callback {HostActivityDelegator mHostActivityDelegator;public void onCreate(Bundle savedInstanceState) {mHostActivityDelegator.superOnCreate(savedInstanceState);}
}

宿主调用插件onCrate方法,插件会通过mHostActivityDelegator回调到宿主的super,即mHostActivityDelegator.superOnCreate(savedInstanceState);

public void superOnCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);}

到这整个流程就跑通了。

结尾

以我们多年的插件环境下业务开发经验,插件框架是不可能一步到位实现完美的。 因此,我们相信大部分业务在接入时都是需要一定的二次开发工作。 Shadow 现有的代码满足的是我们自己的业务现在的需求。得益于全动态的设计, 插件框架和插件本身都是动态发布的,插件包里既有插件代码也有插件框架代码, 所以可以根据新版本插件的需要同时开发插件框架。

例如,ShadowActivity 没有实现全所有 Activity 方法,你写的测试代码可能用到了, 就会出现 Method Not Found 错误,只需要在 ShadowActivity 中实现对应方法就可以了。 大部分方法的实现都只是需要简单的转调就能工作正常。

如果遇到不会实现的功能,可以提 Issue。最好附上测试代码。如有更多问题可以私信!

Shadow 腾讯插件化——深度解剖框架设计相关推荐

  1. Android插件化(使用Small框架)

    github: https://github.com/cayden/MySmall Android插件化(使用Small框架) 框架源代码 1. Create Project File->New ...

  2. Android 手写实现插件化换肤框架 兼容Android10 Android11

    目录 一.收集所有需要换肤的view及相关属性 二.统一为所有Activity设置工厂(兼容Android9以上) 三.加载皮肤包资源 四.处理支持库或者自定义view的换肤 五.处理状态栏换肤 六. ...

  3. 饮水思源--浅析深度学习框架设计中的关键技术

    点击上方"深度学习大讲堂"可订阅哦! 编者按:如果把深度学习比作一座城,框架则是这座城中的水路系统,而基于拓扑图的计算恰似城中水的流动,这种流动赋予了这座城以生命.一个优雅的框架在 ...

  4. android插件化-获取apkplug框架已安装插件-03

    上一篇文章成功的将apkplug框架嵌入了应用中而且启动 链接http://www.apkplug.com/blog/?post=10 这一篇文章实现怎样获取全部已安装插件 一 获取框架的System ...

  5. Winform开发框架之插件化应用框架实现

    支持插件化应用的开发框架能给程序带来无穷的生命力,也是目前很多系统.程序追求的重要方向之一,插件化的模块,在遵循一定的接口标准的基础上,可以实现快速集成,也就是所谓的热插拔操作,可以无限对已经开发好系 ...

  6. Android组件化与插件化开发项目实战整理分享(含支付宝、360、美团、滴滴等大厂项目实战)

    小公司不说,但是在大公司的项目发展到一定程度,就必须进行模块的拆分.模块化是一种指导理念,其核心思想就是分而治之.降低耦合.而在 Android 开发的实践,目前有两种途径来实现,一个是组件化,一个是 ...

  7. 360手机卫士插件化RePlugin今日开源,官方全面解读

    作者:张炅轩,360手机卫士·客户端技术专家 写在前面 "RePlugin将在6月底开源,这将是我们献给安卓世界最好的礼物."当我们宣布这一消息时,心中的激动,无以言表.是的,三年 ...

  8. android插件化、组件化、热补丁傻傻分不清

    时至今日,国内的android技术可谓是走在世界前沿,这或许还得感谢这堵"墙"的作用,正所谓哪里有压迫哪里就有反抗啊 从2015年中旬,android插件化的兴起,到2016年底, ...

  9. android 程序开发的插件化

    本文为 博客园 黑暗伯爵 原创,转载请注明  http://hangxin1940.cnblogs.com 原文地址:android 程序开发的插件化 模块化方法 之一 框架已经放出: android ...

最新文章

  1. 全球智能制造发展现状及前景预测 工业机器人引领行业发展
  2. R语言数据可视化 ggplot2基础1 ggplot2 图形的分层语法 Layered Grammar 简介
  3. uniapph5授权成功后返回上一页_被成功验证过的的7条选品思路(收藏)
  4. luogu P4726 多项式指数函数(模板题FFT、多项式求逆、多项式对数函数)
  5. C++Gaussian-elimination高斯消元法的实现算法(附完整源码)
  6. go 语言系列(二)基本数据类型和操作符
  7. asp.net 添加成功弹出个div提示_IOS12免越狱一键修改微信提示音
  8. [编写高质量代码:改善java程序的151个建议]建议101 Class类
  9. 【CCCC】L2-027 名人堂与代金券 (25分),模拟水题
  10. Java中的内部类与匿名内部类
  11. 基于java网上购物系统论文,基于Java的网上购物系统的设计与实现_毕业设计(论文).doc...
  12. 【雷达信号处理基础】第1讲 -- 雷达系统概述
  13. 软件开发公司能开发哪些类型的app软件
  14. Simulink仿真---clark变换、反clark变换
  15. Python ADF 单位根检验 结果理解
  16. Steam帐号被盗怎么办
  17. 基于区块链的二维码门禁系统成品演示视频
  18. Kinect v2保存图像和深度图序列
  19. 苹果手机输入汉字显示拼音和汉字问题
  20. 孩子该不该学编程?学编程有用吗?

热门文章

  1. Java创建对象方式初谈
  2. AutoSar CAN网络管理状态机理解
  3. 手机抢红包助手深陷作弊指责 部分外挂植入木马
  4. 浪涌保护器,SPD浪涌保护器的分类和选型标准
  5. 上帝视角看区块链项目
  6. 找不到设备.将计算机连接,win10系统宽带连接显示不可使用找不到设备的修复方法...
  7. 阿里云服务器一直提示安全事件如何解决
  8. python 天天基金数据接口
  9. 最新阿里云服务器和GPU服务器长期优惠活动价格表
  10. 文件是存储在计算机外存上的,计算机存储器——内存和外存.doc