一、背景:

插件化的第一代目,任玉刚大神的dynamic-load-apk。目前插件化的方案主要有以Dynamic-load-apk为代表的的静态代理方案,以及以张勇的DroidPlugin为代表的动态代理hook系统AMS和PM的方案


  • 第一种方案,使用静态代理插件的方案,来代理插件apk中Activity的生命周期管理。

  • 第二种方案,使用动态代理hook系统AMS的方式,来拦截AMS启动Activity和Activity生命周期的逻辑,通过使用选坑位Activity来骗过AMS,回调到Client端再脱坑脱壳,启动targetActivity来实现。

二、解析:

这里先逐步解析一下Dynamic-load-apk的原理。

插件化涉及到几个重要的问题:

  1. 插件中资源和host宿主资源的管理(资源ID冲突为题等)
  2. 插件中四大组件的生命周期管理
  • 插件中资源的管理
    我们查看Dynamic-load-apk的工程代码,可以从DLPluginManager这个类开始。
    运行插件的第一步,肯定是加载插件apk文件。

    public DLPluginPackage loadApk(final String dexPath, boolean hasSoLib) {//标识来自外部,即从宿主调用mFrom = DLConstants.FROM_EXTERNAL;//解析插件apk的packageInfoPackageInfo packageInfo = mContext.getPackageManager().getPackageArchiveInfo(dexPath,PackageManager.GET_ACTIVITIES | PackageManager.GET_SERVICES);if (packageInfo == null) {return null;}//1. 准备插件运行的环境DLPluginPackage pluginPackage = preparePluginEnv(packageInfo, dexPath);///2. 如果有so文件,则需要拷贝so文件if (hasSoLib) {copySoLib(dexPath);}return pluginPackage;}

    我们先看注释1:准备插件运行环境

    /*** prepare plugin runtime env, has DexClassLoader, Resources, and so on.* * @param packageInfo* @param dexPath* @return*/
    private DLPluginPackage preparePluginEnv(PackageInfo packageInfo, String dexPath) {//从缓存中拿PluginPackage对象DLPluginPackage pluginPackage = mPackagesHolder.get(packageInfo.packageName);if (pluginPackage != null) {return pluginPackage;}//为当前插件创建独立的DexClassLoaderDexClassLoader dexClassLoader = createDexClassLoader(dexPath);//为当前插件创建资源管理AssetManagerAssetManager assetManager = createAssetManager(dexPath);//为当前插件创建资源ResourceResources resources = createResources(assetManager);// create pluginPackage  创建新的pluginPackage对象。PluginPackage对象持有dexClassLoader、Resource、插件自己的PackageInfopluginPackage = new DLPluginPackage(dexClassLoader, resources, packageInfo);//放进缓存mPackagesHolder.put(packageInfo.packageName, pluginPackage);return pluginPackage;
    }

    我们再分别看cerateDexClassLoader()createAssetManager()createResource()

    • createDexClassLoder(dexpath) 为当前插件创建ClassLoader。此处提一下:pathClassLoader和dexClassLoader的区别:

      1. 首先,二者都是继承自BaseDexClassLoder
      2. pathClassLoader只支持安装的apk,dexClassLoader可以支持dex、未安装的apk、aar、jar等
    private DexClassLoader createDexClassLoader(String dexPath) {File dexOutputDir = mContext.getDir("dex", Context.MODE_PRIVATE);dexOutputPath = dexOutputDir.getAbsolutePath();//插件所在目录,dexopt后所在目录,插件目录下放so的路径、父classloaderDexClassLoader loader = new DexClassLoader(dexPath, dexOutputPath, mNativeLibDir, mContext.getClassLoader());return loader;
    }
    • createAssetManager(dexpath) 将插件的资源加入到宿主中
     private AssetManager createAssetManager(String dexPath) {try {AssetManager assetManager = AssetManager.class.newInstance();Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);//将插件的资源一起加入到AssetManager中addAssetPath.invoke(assetManager, dexPath);return assetManager;} catch (Exception e) {e.printStackTrace();return null;}}
    • createResources(assetManager) 为当前插件创建资源对象
    private Resources createResources(AssetManager assetManager) {Resources superRes = mContext.getResources();Resources resources = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());return resources;
    }
    
  • 再看注释2:将so文件copy到插件目录下

      private void copySoLib(String dexPath) {// TODO: copy so lib async will lead to bugs maybe, waiting for// resolved later.// TODO : use wait and signal is ok ? that means when copying the// .so files, the main thread will enter waiting status, when the// copy is done, send a signal to the main thread.// new Thread(new CopySoRunnable(dexPath)).start();SoLibManager.getSoLoader().copyPluginSoLib(mContext, dexPath, mNativeLibDir);
    }/*** copy so lib to specify directory(/data/data/host_pack_name/pluginlib)** @param dexPath      plugin path* @param nativeLibDir nativeLibDir*/
    public void copyPluginSoLib(Context context, String dexPath, String nativeLibDir) {String cpuName = getCpuName();String cpuArchitect = getCpuArch(cpuName);sNativeLibDir = nativeLibDir;Log.d(TAG, "cpuArchitect: " + cpuArchitect);long start = System.currentTimeMillis();try {ZipFile zipFile = new ZipFile(dexPath);Enumeration<? extends ZipEntry> entries = zipFile.entries();while (entries.hasMoreElements()) {ZipEntry zipEntry = (ZipEntry) entries.nextElement();if (zipEntry.isDirectory()) {continue;}String zipEntryName = zipEntry.getName();if (zipEntryName.endsWith(".so") && zipEntryName.contains(cpuArchitect)) {final long lastModify = zipEntry.getTime();if (lastModify == DLConfigs.getSoLastModifiedTime(context, zipEntryName)) {// exist and no changeLog.d(TAG, "skip copying, the so lib is exist and not change: " + zipEntryName);continue;}mSoExecutor.execute(new CopySoTask(context, zipFile, zipEntry, lastModify));}}} catch (IOException e) {e.printStackTrace();}long end = System.currentTimeMillis();Log.d(TAG, "### copy so time : " + (end - start) + " ms");
    }private class CopySoTask implements Runnable {private String mSoFileName;private ZipFile mZipFile;private ZipEntry mZipEntry;private Context mContext;private long mLastModityTime;CopySoTask(Context context, ZipFile zipFile, ZipEntry zipEntry, long lastModify) {mZipFile = zipFile;mContext = context;mZipEntry = zipEntry;mSoFileName = parseSoFileName(zipEntry.getName());mLastModityTime = lastModify;}private final String parseSoFileName(String zipEntryName) {return zipEntryName.substring(zipEntryName.lastIndexOf("/") + 1);}private void writeSoFile2LibDir() throws IOException {InputStream is = null;FileOutputStream fos = null;is = mZipFile.getInputStream(mZipEntry);fos = new FileOutputStream(new File(sNativeLibDir, mSoFileName));copy(is, fos);mZipFile.close();}/*** 输入输出流拷贝** @param is* @param os*/public void copy(InputStream is, OutputStream os) throws IOException {if (is == null || os == null)return;BufferedInputStream bis = new BufferedInputStream(is);BufferedOutputStream bos = new BufferedOutputStream(os);int size = getAvailableSize(bis);byte[] buf = new byte[size];int i = 0;while ((i = bis.read(buf, 0, size)) != -1) {bos.write(buf, 0, i);}bos.flush();bos.close();bis.close();}private int getAvailableSize(InputStream is) throws IOException {if (is == null)return 0;int available = is.available();return available <= 0 ? 1024 : available;}@Overridepublic void run() {try {writeSoFile2LibDir();DLConfigs.setSoLastModifiedTime(mContext, mZipEntry.getName(), mLastModityTime);Log.d(TAG, "copy so lib success: " + mZipEntry.getName());} catch (IOException e) {Log.e(TAG, "copy so lib failed: " + e.toString());e.printStackTrace();}}}
    

    到此,整个插件的load过程全部完成,主要包括:

    1. 为插件准备运行环境
    2. copy出插件的so文件 整个过程通过loadApk加载插件会得到PluginPackager对象,
      该对象持有该插件的classLoader、该插件的资源resources、该插件的packageInfo。每个插件加载完之后,PluginPackager都会存储中缓存mPackagesHolder中,为后期启动插件做准备。
  • 启动插件(以Activity为例子)
    DlPluginManager.startPluginActivity(Context context, DLIntent dlIntent)会调用到startPluginActivityForResult(Context context, DLIntent dlIntent, int requestCode)

    /*** @param context* @param dlIntent* @param requestCode* @return One of below: {@link #START_RESULT_SUCCESS}*         {@link #START_RESULT_NO_PKG} {@link #START_RESULT_NO_CLASS}*         {@link #START_RESULT_TYPE_ERROR}*/@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)public int startPluginActivityForResult(Context context, DLIntent dlIntent, int requestCode) {//如果是插件内部调用,则不用代理中转,直接打开if (mFrom == DLConstants.FROM_INTERNAL) {dlIntent.setClassName(context, dlIntent.getPluginClass());performStartActivityForResult(context, dlIntent, requestCode);return DLPluginManager.START_RESULT_SUCCESS;}//如果是外部打开插件Activity,则需要一系列操作//1、获取插件包名String packageName = dlIntent.getPluginPackage();if (TextUtils.isEmpty(packageName)) {throw new NullPointerException("disallow null packageName.");}//根据插件包名,从缓存中获取之前加载加载解析到的DLPluginPackager对象DLPluginPackage pluginPackage = mPackagesHolder.get(packageName);if (pluginPackage == null) {return START_RESULT_NO_PKG;}//获取Activity完整路径的名称:包名+ActivityClass类名final String className = getPluginActivityFullPath(dlIntent, pluginPackage);//用该插件自己的DexClassLoader去加载这个Activity类Class<?> clazz = loadPluginClass(pluginPackage.classLoader, className);if (clazz == null) {return START_RESULT_NO_CLASS;}// get the proxy activity class, the proxy activity will launch the// plugin activity.//根据插件的Activity去拿到插件Activity代理的ActivityClass<? extends Activity> activityClass = getProxyActivityClass(clazz);if (activityClass == null) {return START_RESULT_TYPE_ERROR;}// put extra data  往dlIntent中塞数据,给ProxyActivity使用dlIntent.putExtra(DLConstants.EXTRA_CLASS, className);dlIntent.putExtra(DLConstants.EXTRA_PACKAGE, packageName);dlIntent.setClass(mContext, activityClass);//打开ActivityperformStartActivityForResult(context, dlIntent, requestCode);return START_RESULT_SUCCESS;}public int startPluginService(final Context context, final DLIntent dlIntent) {if (mFrom == DLConstants.FROM_INTERNAL) {dlIntent.setClassName(context, dlIntent.getPluginClass());context.startService(dlIntent);return DLPluginManager.START_RESULT_SUCCESS;}fetchProxyServiceClass(dlIntent, new OnFetchProxyServiceClass() {@Overridepublic void onFetch(int result, Class<? extends Service> proxyServiceClass) {// TODO Auto-generated method stubif (result == START_RESULT_SUCCESS) {dlIntent.setClass(context, proxyServiceClass);// start代理Servicecontext.startService(dlIntent);}mResult = result;}});return mResult;}

    看看如何找到代理Activity的getProxyActivityClass(clazz)

    /*** get the proxy activity class, the proxy activity will delegate the plugin* activity* * @param clazz*            target activity's class* @return*/private Class<? extends Activity> getProxyActivityClass(Class<?> clazz) {Class<? extends Activity> activityClass = null;//如果插件的Activity是继承自DLBasePluginActivity,则它在宿主host中的代理Activity是DLProxyActivityif (DLBasePluginActivity.class.isAssignableFrom(clazz)) {activityClass = DLProxyActivity.class;//同上} else if (DLBasePluginFragmentActivity.class.isAssignableFrom(clazz)) {activityClass = DLProxyFragmentActivity.class;}return activityClass;}
    

    为插件Activty找到在host中的代理Activity后,所以生命周期都交给代理Activity来实现。

    // put extra data  往dlIntent中塞数据,给ProxyActivity使用dlIntent.putExtra(DLConstants.EXTRA_CLASS, className);dlIntent.putExtra(DLConstants.EXTRA_PACKAGE, packageName);dlIntent.setClass(mContext, activityClass);//打开Activity  这里先打开activityClass,也就是host中的代理ActivityperformStartActivityForResult(context, dlIntent, requestCode);
    

    宿主host中的代理Activity有两种类型:DLProxyActivity和DLProxyFragmentActvity。
    我们拿DlProxyActivity来看,另一个同理

    /*** HOST中的代理Activity,所有插件的生命周期都是通过它来代理的*/
    public class DLProxyActivity extends Activity implements DLAttachable {protected DLPlugin mRemoteActivity;//关键类DLPrpxyImpl是真正的实现代理类private DLProxyImpl impl = new DLProxyImpl(this);@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);//获取塞过来的DlIntent来代理插件Activity的OnCreateimpl.onCreate(getIntent());}@Overridepublic void attach(DLPlugin remoteActivity, DLPluginManager pluginManager) {//将插件Activity与当前代理Activity绑定mRemoteActivity = remoteActivity;}//AssetManager的代理@Overridepublic AssetManager getAssets() {return impl.getAssets() == null ? super.getAssets() : impl.getAssets();}//Resources的代理@Overridepublic Resources getResources() {return impl.getResources() == null ? super.getResources() : impl.getResources();}//getTheme的代理@Overridepublic Theme getTheme() {return impl.getTheme() == null ? super.getTheme() : impl.getTheme();}@Overridepublic ClassLoader getClassLoader() {return impl.getClassLoader();}//以下是其他生命周期回调和其他一些回调方法的代理@Overrideprotected void onActivityResult(int requestCode, int resultCode, Intent data) {mRemoteActivity.onActivityResult(requestCode, resultCode, data);super.onActivityResult(requestCode, resultCode, data);}@Overrideprotected void onStart() {mRemoteActivity.onStart();super.onStart();}@Overrideprotected void onRestart() {mRemoteActivity.onRestart();super.onRestart();}...}

    关键类ProxyImpl(代理的真正实现)

    
    /*** This is a plugin activity proxy, the proxy will create the plugin activity* with reflect, and then call the plugin activity's attach、onCreate method, at* this time, the plugin activity is running.** @author mrsimple*/
    public class DLProxyImpl {private static final String TAG = "DLProxyImpl";private String mClass;private String mPackageName;private DLPluginPackage mPluginPackage;private DLPluginManager mPluginManager;private AssetManager mAssetManager;private Resources mResources;private Theme mTheme;private ActivityInfo mActivityInfo;private Activity mProxyActivity;protected DLPlugin mPluginActivity;public ClassLoader mPluginClassLoader;public DLProxyImpl(Activity activity) {mProxyActivity = activity;}/*** 初始化ActivityInfo  主要是主题*/private void initializeActivityInfo() {PackageInfo packageInfo = mPluginPackage.packageInfo;if ((packageInfo.activities != null) && (packageInfo.activities.length > 0)) {if (mClass == null) {mClass = packageInfo.activities[0].name;}//Finals 修复主题BUGint defaultTheme = packageInfo.applicationInfo.theme;for (ActivityInfo a : packageInfo.activities) {if (a.name.equals(mClass)) {mActivityInfo = a;// Finals ADD 修复主题没有配置的时候插件异常if (mActivityInfo.theme == 0) {if (defaultTheme != 0) {mActivityInfo.theme = defaultTheme;} else {if (Build.VERSION.SDK_INT >= 14) {mActivityInfo.theme = android.R.style.Theme_DeviceDefault;} else {mActivityInfo.theme = android.R.style.Theme;}}}}}}}//为Activity设置主题private void handleActivityInfo() {Log.d(TAG, "handleActivityInfo, theme=" + mActivityInfo.theme);if (mActivityInfo.theme > 0) {mProxyActivity.setTheme(mActivityInfo.theme);}Theme superTheme = mProxyActivity.getTheme();mTheme = mResources.newTheme();mTheme.setTo(superTheme);// Finals适配三星以及部分加载XML出现异常BUGtry {mTheme.applyStyle(mActivityInfo.theme, true);} catch (Exception e) {e.printStackTrace();}// TODO: handle mActivityInfo.launchMode here in the future.}/*** 最关键的方法你,外部启动插件内Activity会跑到这里** @param intent*/public void onCreate(Intent intent) {// set the extra's class loaderintent.setExtrasClassLoader(DLConfigs.sPluginClassloader);//获取插件包名mPackageName = intent.getStringExtra(DLConstants.EXTRA_PACKAGE);//插件Activity类名mClass = intent.getStringExtra(DLConstants.EXTRA_CLASS);Log.d(TAG, "mClass=" + mClass + " mPackageName=" + mPackageName);mPluginManager = DLPluginManager.getInstance(mProxyActivity);mPluginPackage = mPluginManager.getPackage(mPackageName);mAssetManager = mPluginPackage.assetManager;mResources = mPluginPackage.resources;//初始化ActivityInfoinitializeActivityInfo();//为activity设置主题handleActivityInfo();//启动插件ActivitylaunchTargetActivity();}/*** 启动插件内的activity:* 1、先反射创建Activity实例* 2、调用插件Activity的onCreate* 3、由于ProxyActivity其实是一个空Activity,所以ProxyActivity相当于一个壳子,只是负责代理回调方法*/@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)protected void launchTargetActivity() {try {Class<?> localClass = getClassLoader().loadClass(mClass);Constructor<?> localConstructor = localClass.getConstructor(new Class[]{});Object instance = localConstructor.newInstance(new Object[]{});mPluginActivity = (DLPlugin) instance;((DLAttachable) mProxyActivity).attach(mPluginActivity, mPluginManager);Log.d(TAG, "instance = " + instance);// attach the proxy activity and plugin package to the mPluginActivitymPluginActivity.attach(mProxyActivity, mPluginPackage);Bundle bundle = new Bundle();bundle.putInt(DLConstants.FROM, DLConstants.FROM_EXTERNAL);mPluginActivity.onCreate(bundle);} catch (Exception e) {e.printStackTrace();}}public ClassLoader getClassLoader() {return mPluginPackage.classLoader;}public AssetManager getAssets() {return mAssetManager;}public Resources getResources() {return mResources;}public Theme getTheme() {return mTheme;}public DLPlugin getRemoteActivity() {return mPluginActivity;}
    }
    

    插件中Acitivity的例子

    public class MainActivity extends DLBasePluginActivity {private static final String TAG = "MainActivity";@Overridepublic void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);initView(savedInstanceState);}private void initView(Bundle savedInstanceState) {that.setContentView(generateContentView(that));}private View generateContentView(final Context context) {LinearLayout layout = new LinearLayout(context);layout.setOrientation(LinearLayout.VERTICAL);layout.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT));Button button = new Button(context);button.setText("Invoke host method");layout.addView(button, LayoutParams.MATCH_PARENT,LayoutParams.WRAP_CONTENT);button.setOnClickListener(new OnClickListener() {@Overridepublic void onClick(View v) {TestHostClass testHostClass = new TestHostClass();testHostClass.testMethod(that);}});TextView textView = new TextView(context);textView.setText("Hello, I'm Plugin B.");textView.setTextSize(30);layout.addView(textView, LayoutParams.MATCH_PARENT,LayoutParams.WRAP_CONTENT);return layout;}@Overridepublic void onActivityResult(int requestCode, int resultCode, Intent data) {Log.d(TAG, "onActivityResult resultCode=" + resultCode);if (resultCode == RESULT_FIRST_USER) {that.finish();}super.onActivityResult(requestCode, resultCode, data);}@Overridepublic boolean onCreateOptionsMenu(Menu menu) {// Inflate the menu; this adds items to the action bar if it is present.getMenuInflater().inflate(R.menu.main, menu);return true;}
    }

    到此整个启动过程解析完成,核心是:

    1. 插件中Activity必须都继承自DLBasePluginActivity或DLBaseFragmentActvity
    2. DLProxyActivity或DlPrxoyFragment作为空壳代理了插件Activity的所有回调方法
  • Service的启动也类似。宿主也是有个代理Service叫DLProxyService,插件中的Service必须继承DLBasePluginService

Dynamic-load-apk插件原理解析相关推荐

  1. MyBatis插件原理解析及自定义插件实践

    一.插件原理解析 首先,要搞清楚插件的作用.不管是我们自定义插件,还是用其他人开发好的第三方插件,插件都是对MyBatis的四大核心组件:Executor,StatementHandler,Param ...

  2. android黑科技系列——微信抢红包插件原理解析和开发实现

    一.前言 自从几年前微信添加抢红包的功能,微信的电商之旅算是正式开始正式火爆起来.但是作为Android开发者来说,我们在抢红包的同时意识到了很多问题,就是手动去抢红包的速度慢了,当然这些有很多原因导 ...

  3. http-invoker插件原理解析

    背景 项目中不可避免的使用第三方api接口,但是如果使用apache-httpclient总是不可避免需要设置连接池等,即使每次拷贝对于项目来说也显得冗余 http-api-invoker的方式可以使 ...

  4. 自定义实现webpack插件原理解析

    webpack插件构成部分 一个具名javascript函数 在他的原型上定义apply方法 指定一个触及到 webpack本身的事件钩子 操作webpack内部的特定实例数据 实现功能后调用 web ...

  5. Android插件化原理解析——ContentProvider的插件化

    目前为止我们已经完成了Android四大组件中Activity,Service以及BroadcastReceiver的插件化,这几个组件各不相同,我们根据它们的特点定制了不同的插件化方案:那么对于Co ...

  6. Android插件化原理解析——广播的管理

    在Activity生命周期管理 以及 插件加载机制 中我们详细讲述了插件化过程中对于Activity组件的处理方式,为了实现Activity的插件化我们付出了相当多的努力:那么Android系统的其他 ...

  7. 插件化原理解析——广播的管理

    在Activity生命周期管理 以及 插件加载机制 中我们详细讲述了插件化过程中对于Activity组件的处理方式,为了实现Activity的插件化我们付出了相当多的努力:那么Android系统的其他 ...

  8. 插件化原理解析——ContentProvider的插件化

    目前为止我们已经完成了Android四大组件中Activity,Service以及BroadcastReceiver的插件化,这几个组件各不相同,我们根据它们的特点定制了不同的插件化方案:那么对于Co ...

  9. Android 插件化原理解析——插件加载机制

    上文 Activity生命周期管理 中我们地完成了『启动没有在AndroidManifest.xml中显式声明的Activity』的任务:通过Hook AMS和拦截ActivityThread中H类对 ...

  10. Android 插件化原理解析——Activity生命周期管理

    之前的 Android插件化原理解析 系列文章揭开了Hook机制的神秘面纱,现在我们手握倚天屠龙,那么如何通过这种技术完成插件化方案呢?具体来说,插件中的Activity,Service等组件如何在A ...

最新文章

  1. 社会工程学到底有多可怕
  2. [IOI2014]Wall
  3. Android生存指南:解Bug策略和思路
  4. ASP.NET MVC ActionMethodSelectorAttribute 以及HttpGet等Action特性
  5. 前端学习(2818):小程序学习之新建页面
  6. [转]图片处理函数(自适应缩略图datatable中添加缩略图像)
  7. iOS 数据解析之使用TFHpple解析html
  8. 模拟grid点击事件
  9. Reduce归约 证明原理
  10. noi.ac #529 神树的矩阵
  11. 统计占比_Excel数据透视表:统计各项所占百分比
  12. 几款软件需求分析工具
  13. Android APP测试流程
  14. 约瑟夫问题(Josephus problem)详解
  15. Cherno C++ P61 C++的命名空间
  16. APISpace 人像比对API
  17. python中的帮助系统_python系统模块
  18. ios中html怎么横屏,苹果xsmax页面怎么横屏
  19. 以太坊开发入门-第一个程序
  20. 玩转FFmpeg的7个小技巧

热门文章

  1. 【对讲机的那点事】玩对讲机你知道中继台的工作原理吗?
  2. MCAFEE卸载软件测试初学者,迈克菲卸载软件怎么用(手把手教你彻底卸载干净)...
  3. ftp服务器上传大文件,关于大文件上传的FTP解决方案
  4. 业务系统监控解决方案
  5. 医库软件-珍立拍 成功晋级黑马大赛总决赛
  6. H5横竖屏的两种解决方法
  7. bug解决 2021-09-25 Unity人物动画无法正常播放的问题
  8. Java中解密微信加密数据工具类
  9. Spring5 系统架构
  10. 基于仿真软件multisim14的多路抢答器电路设计