随着应用的体积越来越大, 插件化也逐渐受到关注, 参考. 应用插件化把模块完全解耦, 使用下载更新的方式, 扩展应用, 是平台化类应用的必然选择. 国内很多公司实现了各式各样的方法, 360的DroidPlugin是比较有意思的一个, 使用预占位的方式注册四大组件, 实现热更新, 参考, 也可以直接读源码理解实现逻辑.

Droid Plugin

Talk is cheap, show you the code! 如何把DroidPlugin用起来呢? 这是我比较关注的事情, 开源的Demo写的如此悲伤, 我来重新梳理一下, 又添加了几个功能测试. 引入DroidPlugin作为Submodule的依赖.

Github下载地址 和 测试Apk.

使用方法, 生成测试apk, 和其他若干apk, 放入Download文件夹下.
adb命令: adb push app-debug.apk /sdcard/Download/app-debug.apk
确保Download文件夹下, 有.apk后缀名的文件.

主要
(1) 插件安装后, 可以直接启动, 不需要任何冗余操作.
(2) 宿主的权限要多于插件的权限, 否则会权限不足.
(3) 宿主和插件, 可以通过隐式Intent进行通信.

动画

1. 主页

使用TabLayout+ViewPager的架构, 包含两个页面, 一个是安装\删除页面, 另一个是启动\卸载页面. 为了测试和插件的Intent通信, 增加跳转功能和显示信息功能.

/*** 主页面, 使用TabLayout+ViewPager.* 子页面, 使用RecyclerView.** @author wangchenlong*/
public class MainActivity extends AppCompatActivity {@Bind(R.id.main_tl_tabs) TabLayout mTlTabs; // Tabs@Bind(R.id.main_vp_container) ViewPager mVpContainer; // ViewPager@Bind(R.id.main_b_goto) Button mBGoto; // 跳转插件的按钮@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);ButterKnife.bind(this);PagerAdapter adapter = new PagerAdapter(getSupportFragmentManager());mVpContainer.setAdapter(adapter);mTlTabs.setupWithViewPager(mVpContainer);mBGoto.setOnClickListener(this::gotoPlugin);Intent intent = getIntent();if (intent != null && intent.getStringExtra(PluginConsts.MASTER_EXTRA_STRING) != null) {String words = "say: " + intent.getStringExtra(PluginConsts.MASTER_EXTRA_STRING);Toast.makeText(this, words, Toast.LENGTH_SHORT).show();}}// 跳转控件private void gotoPlugin(View view) {if (isActionAvailable(view.getContext(), PluginConsts.PLUGIN_ACTION_MAIN)) {Intent intent = new Intent(PluginConsts.PLUGIN_ACTION_MAIN);intent.putExtra(PluginConsts.PLUGIN_EXTRA_STRING, "Hello, My Plugin!");startActivity(intent);} else {Toast.makeText(view.getContext(), "跳转失败", Toast.LENGTH_SHORT).show();}}// Action是否允许public static boolean isActionAvailable(Context context, String action) {Intent intent = new Intent(action);return context.getPackageManager().resolveActivity(intent, 0) != null;}
}

ViewPager适配器

/*** ViewPager的适配器* <p>* Created by wangchenlong on 16/1/8.*/
public class PagerAdapter extends FragmentPagerAdapter {private static final String[] TITLES = {"已安装","未安装"};public PagerAdapter(FragmentManager fm) {super(fm);}@Override public Fragment getItem(int position) {if (position == 0) {return new StartFragment(); // 已安装页} else {return new StoreFragment(); // 想要安装页}}@Override public int getCount() {return TITLES.length;}@Overridepublic CharSequence getPageTitle(int position) {return TITLES[position];}
}

2. 加载页

显示Download文件夹下的Apk信息. 使用RecyclerView实现apk列表, 复用Adapter, 标志位(ApkOperator.TYPE_STORE)区分页面. 使用Rx异步扫描Download文件夹, 添加至列表. 接收服务连接状态, 成功则自动显示Apk.

/*** 安装Apk的页面, 使用RecyclerView.* <p>* Created by wangchenlong on 16/1/8.*/
public class StoreFragment extends Fragment {@Bind(R.id.list_rv_recycler) RecyclerView mRvRecycler;private ApkListAdapter mStoreAdapter; // 适配器// 服务连接private ServiceConnection mServiceConnection = new ServiceConnection() {@Override public void onServiceConnected(ComponentName name, IBinder service) {loadApks();}@Override public void onServiceDisconnected(ComponentName name) {}};@Nullable @Overridepublic View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {View view = inflater.inflate(R.layout.fragment_list, container, false);ButterKnife.bind(this, view);return view;}@Override public void onViewCreated(View view, Bundle savedInstanceState) {super.onViewCreated(view, savedInstanceState);LinearLayoutManager llm = new LinearLayoutManager(view.getContext());llm.setOrientation(LinearLayoutManager.VERTICAL);mRvRecycler.setLayoutManager(llm);mStoreAdapter = new ApkListAdapter(getActivity(), ApkOperator.TYPE_STORE);mRvRecycler.setAdapter(mStoreAdapter);if (PluginManager.getInstance().isConnected()) {loadApks();} else {PluginManager.getInstance().addServiceConnection(mServiceConnection);}}// 加载Apkprivate void loadApks() {// 异步加载, 防止Apk过多, 影响速度Observable.just(getApkFromDownload()).subscribeOn(Schedulers.newThread()).observeOn(AndroidSchedulers.mainThread()).subscribe(mStoreAdapter::setApkItems);}// 从下载文件夹获取Apkprivate ArrayList<ApkItem> getApkFromDownload() {File files = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);PackageManager pm = getActivity().getPackageManager();ArrayList<ApkItem> apkItems = new ArrayList<>();for (File file : files.listFiles()) {if (file.exists() && file.getPath().toLowerCase().endsWith(".apk")) {final PackageInfo info = pm.getPackageArchiveInfo(file.getPath(), 0);apkItems.add(new ApkItem(pm, info, file.getPath()));}}return apkItems;}@Overridepublic void onDestroyView() {super.onDestroyView();ButterKnife.unbind(this);PluginManager.getInstance().removeServiceConnection(mServiceConnection);}
}

适配器, 负责列表显示, 操作交由ViewHolder进行处理.

/*** 启动的适配器* <p>* Created by wangchenlong on 16/1/13.*/
public class ApkListAdapter extends RecyclerView.Adapter<ApkItemViewHolder> {private ArrayList<ApkItem> mApkItems;private Activity mActivity;private int mType; // 类型public ApkListAdapter(Activity activity, int type) {mActivity = activity;mApkItems = new ArrayList<>();mType = type;}public void setApkItems(ArrayList<ApkItem> apkItems) {mApkItems = apkItems;notifyDataSetChanged();}public void addApkItem(ApkItem apkItem) {mApkItems.add(apkItem);notifyItemInserted(mApkItems.size() + 1);}public void removeApkItem(ApkItem apkItem) {mApkItems.remove(apkItem);notifyDataSetChanged();}public ApkItem getApkItem(int index) {return mApkItems.get(index);}public int getCount() {return mApkItems.size();}@Override public ApkItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.apk_item, parent, false);return new ApkItemViewHolder(mActivity, view, mType, this::removeApkItem);}@Override public void onBindViewHolder(ApkItemViewHolder holder, int position) {holder.bindTo(mApkItems.get(position));}@Override public int getItemCount() {return mApkItems.size();}
}

注意, 在设置Item时, 需要刷新列表, 使用notifyDataSetChanged或notifyItemInserted.

ViewHolder, 控制列表点击事件, 根据页面类型, 修改调用方法.

/*** Apk的列表, 参考: R.layout.apk_item* <p>* Created by wangchenlong on 16/1/13.*/
public class ApkItemViewHolder extends RecyclerView.ViewHolder {@Bind(R.id.apk_item_iv_icon) ImageView mIvIcon; // 图标@Bind(R.id.apk_item_tv_title) TextView mTvTitle; // 标题@Bind(R.id.apk_item_tv_version) TextView mTvVersion; // 版本号@Bind(R.id.apk_item_b_do) Button mBDo; // 确定按钮@Bind(R.id.apk_item_b_undo) Button mBUndo; // 取消按钮private ApkItem mApkItem; // Apk项private Context mContext; // 上下文private ApkOperator mApkOperator; // Apk操作private int mType; // 类型/*** 初始化ViewHolder** @param activity Dialog绑定Activity* @param itemView 项视图* @param type     类型, 加载或启动* @param callback 删除Item的调用*/public ApkItemViewHolder(Activity activity, View itemView, int type, ApkOperator.RemoveCallback callback) {super(itemView);ButterKnife.bind(this, itemView);mContext = activity.getApplicationContext();mApkOperator = new ApkOperator(activity, callback); // Apk操作mType = type; // 类型}// 绑定ViewHolderpublic void bindTo(ApkItem apkItem) {mApkItem = apkItem;mIvIcon.setImageDrawable(apkItem.icon);mTvTitle.setText(apkItem.title);mTvVersion.setText(String.format("%s(%s)", apkItem.versionName, apkItem.versionCode));// 修改文字if (mType == ApkOperator.TYPE_STORE) {mBUndo.setText("删除");mBDo.setText("安装");} else if (mType == ApkOperator.TYPE_START) {mBUndo.setText("卸载");mBDo.setText("启动");}mBUndo.setOnClickListener(this::onClickEvent);mBDo.setOnClickListener(this::onClickEvent);}// 点击事件private void onClickEvent(View view) {if (mType == ApkOperator.TYPE_STORE) {if (view.equals(mBUndo)) {mApkOperator.deleteApk(mApkItem);} else if (view.equals(mBDo)) {// 安装Apk较慢需要使用异步线程new InstallApkTask().execute();}} else if (mType == ApkOperator.TYPE_START) {if (view.equals(mBUndo)) {mApkOperator.uninstallApk(mApkItem);} else if (view.equals(mBDo)) {mApkOperator.openApk(mApkItem);}}}// 安装Apk的线程, Rx无法使用.private class InstallApkTask extends AsyncTask<Void, Void, String> {@Overrideprotected void onPostExecute(String v) {Toast.makeText(mContext, v, Toast.LENGTH_LONG).show();}@Overrideprotected String doInBackground(Void... params) {return mApkOperator.installApk(mApkItem);}}
}

注意, 安装Apk使用异步线程(AsyncTask), 不能使用Rx.

3. 启动页

启动页面, 显示已安装的Apk, 包含启动和卸载功能. 与安装页不同, 额外增加一个接收器, 负责接收安装成功之后的广播, 用于更新列表.

/*** 启动Apk页面* <p>* Created by wangchenlong on 16/1/13.*/
public class StartFragment extends Fragment {@Bind(R.id.list_rv_recycler) RecyclerView mRvRecycler;private ApkListAdapter mApkListAdapter; // 适配器private InstallApkReceiver mInstallApkReceiver; // Apk安装接收器// 服务连接private final ServiceConnection mServiceConnection = new ServiceConnection() {@Override public void onServiceConnected(ComponentName name, IBinder service) {loadApks();}@Override public void onServiceDisconnected(ComponentName name) {}};@Nullable @Overridepublic View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {View view = inflater.inflate(R.layout.fragment_list, container, false);ButterKnife.bind(this, view);return view;}@Override public void onViewCreated(View view, Bundle savedInstanceState) {super.onViewCreated(view, savedInstanceState);LinearLayoutManager llm = new LinearLayoutManager(view.getContext());llm.setOrientation(LinearLayoutManager.VERTICAL);mRvRecycler.setLayoutManager(llm);mApkListAdapter = new ApkListAdapter(getActivity(), ApkOperator.TYPE_START);mRvRecycler.setAdapter(mApkListAdapter);mInstallApkReceiver = new InstallApkReceiver();mInstallApkReceiver.registerReceiver(this.getActivity());if (PluginManager.getInstance().isConnected()) {loadApks();} else {PluginManager.getInstance().addServiceConnection(mServiceConnection);}}@Overridepublic void onDestroyView() {super.onDestroyView();ButterKnife.unbind(this);mInstallApkReceiver.unregisterReceiver(this.getActivity());}// 加载Apkprivate void loadApks() {// 异步加载, 防止Apk过多, 影响速度Observable.just(getApkFromInstall()).subscribeOn(Schedulers.newThread()).observeOn(AndroidSchedulers.mainThread()).subscribe(mApkListAdapter::setApkItems);}// 获取安装中获取Apkprivate ArrayList<ApkItem> getApkFromInstall() {ArrayList<ApkItem> apkItems = new ArrayList<>();try {final List<PackageInfo> infos = PluginManager.getInstance().getInstalledPackages(0);if (infos == null) {return apkItems;}final PackageManager pm = getActivity().getPackageManager();// noinspection allfor (final PackageInfo info : infos) {apkItems.add(new ApkItem(pm, info, info.applicationInfo.publicSourceDir));}} catch (RemoteException e) {e.printStackTrace();}return apkItems;}// 安装Apk接收器private class InstallApkReceiver extends BroadcastReceiver {// 注册监听public void registerReceiver(Context context) {IntentFilter filter = new IntentFilter();filter.addAction(PluginManager.ACTION_PACKAGE_ADDED);filter.addAction(PluginManager.ACTION_PACKAGE_REMOVED);filter.addDataScheme("package");context.registerReceiver(this, filter);}// 关闭监听public void unregisterReceiver(Context context) {context.unregisterReceiver(this);}@Overridepublic void onReceive(Context context, Intent intent) {// 监听添加和删除事件if (PluginManager.ACTION_PACKAGE_ADDED.equals(intent.getAction())) {try {PackageManager pm = getActivity().getPackageManager();String pkg = intent.getData().getAuthority();PackageInfo info = PluginManager.getInstance().getPackageInfo(pkg, 0);mApkListAdapter.addApkItem(new ApkItem(pm, info, info.applicationInfo.publicSourceDir));} catch (Exception e) {e.printStackTrace();}} else if (PluginManager.ACTION_PACKAGE_REMOVED.equals(intent.getAction())) {String pkg = intent.getData().getAuthority();int num = mApkListAdapter.getCount();ApkItem removedItem = null;for (int i = 0; i < num; i++) {ApkItem item = mApkListAdapter.getApkItem(i);if (TextUtils.equals(item.packageInfo.packageName, pkg)) {removedItem = item;break;}}if (removedItem != null) {mApkListAdapter.removeApkItem(removedItem);}}}}
}

复用Adapter和ViewHolder, 代码简介之道.

4. 方法类

四大方法, 安装\删除\启动\卸载, 在删除和卸载时, 均会提示Dialog. 注意的是安装Apk, 耗时较长, 需要使用异步线程.

/*** Apk操作, 包含删除\安装\卸载\启动Apk* <p>* Created by wangchenlong on 16/1/13.*/
public class ApkOperator {public static final int TYPE_STORE = 0; // 存储Apkpublic static final int TYPE_START = 1; // 启动Apkprivate Activity mActivity;       // 绑定Dialogprivate RemoveCallback mCallback; // 删除Item的回调public ApkOperator(Activity activity, RemoveCallback callback) {mActivity = activity;mCallback = callback;}// 删除Apkpublic void deleteApk(final ApkItem item) {AlertDialog.Builder builder = new AlertDialog.Builder(mActivity);builder.setTitle("警告");builder.setMessage("你确定要删除" + item.title + "么?");builder.setNegativeButton("删除", (dialog, which) -> {if (new File(item.apkFile).delete()) {mCallback.removeItem(item);Toast.makeText(mActivity, "删除成功", Toast.LENGTH_SHORT).show();} else {Toast.makeText(mActivity, "删除失败", Toast.LENGTH_SHORT).show();}});builder.setNeutralButton("取消", null);builder.show();}/*** 安装Apk, 耗时较长, 需要使用异步线程** @param item Apk项* @return [0:成功, 1:已安装, -1:连接失败, -2:权限不足, -3:安装失败]*/public String installApk(final ApkItem item) {if (!PluginManager.getInstance().isConnected()) {return "连接失败"; // 连接失败}if (isApkInstall(item)) {return "已安装"; // 已安装}try {int result = PluginManager.getInstance().installPackage(item.apkFile, 0);boolean isRequestPermission = (result == PluginManager.INSTALL_FAILED_NO_REQUESTEDPERMISSION);if (isRequestPermission) {return "权限不足";}} catch (RemoteException e) {e.printStackTrace();return "安装失败";}return "成功";}// Apk是否安装private boolean isApkInstall(ApkItem apkItem) {PackageInfo info = null;try {info = PluginManager.getInstance().getPackageInfo(apkItem.packageInfo.packageName, 0);} catch (RemoteException e) {e.printStackTrace();}return info != null;}// 卸载Apkpublic void uninstallApk(final ApkItem item) {AlertDialog.Builder builder = new AlertDialog.Builder(mActivity);builder.setTitle("警告");builder.setMessage("警告,你确定要卸载" + item.title + "么?");builder.setNegativeButton("卸载", (dialog, which) -> {if (!PluginManager.getInstance().isConnected()) {Toast.makeText(mActivity, "服务未连接", Toast.LENGTH_SHORT).show();} else {try {PluginManager.getInstance().deletePackage(item.packageInfo.packageName, 0);mCallback.removeItem(item);Toast.makeText(mActivity, "卸载完成", Toast.LENGTH_SHORT).show();} catch (RemoteException e) {e.printStackTrace();}}});builder.setNeutralButton("取消", null);builder.show();}// 打开Apkpublic void openApk(final ApkItem item) {PackageManager pm = mActivity.getPackageManager();Intent intent = pm.getLaunchIntentForPackage(item.packageInfo.packageName);intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);mActivity.startActivity(intent);}// 删除Item回调, Adapter调用删除Itempublic interface RemoveCallback {void removeItem(ApkItem apkItem);}
}

5. 互动Apk

为了测试DroidPlugin的一些特性, 又写了一个测试Apk.
测试插件和宿主的通信, 插件类的生命周期.

public class MainActivity extends AppCompatActivity {private static final String TAG = "DEBUG-WCL: " + MainActivity.class.getSimpleName();@Bind(R.id.main_b_goto_master) Button mBGotoMaster;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);ButterKnife.bind(this);Intent intent = getIntent();if (intent != null && intent.getStringExtra(PluginConsts.PLUGIN_EXTRA_STRING) != null) {String words = "say: " + intent.getStringExtra(PluginConsts.PLUGIN_EXTRA_STRING);Toast.makeText(this, words, Toast.LENGTH_SHORT).show();}mBGotoMaster.setOnClickListener(this::gotoMaster);Log.d(TAG, "onCreate"); // 测试生命周期}@Override protected void onDestroy() {super.onDestroy();Log.d(TAG, "onDestroy"); // 测试生命周期}// 跳转控件private void gotoMaster(View view) {if (isActionAvailable(view.getContext(), PluginConsts.MASTER_ACTION_MAIN)) {Intent intent = new Intent(PluginConsts.MASTER_ACTION_MAIN);intent.putExtra(PluginConsts.MASTER_EXTRA_STRING, "Hello, My Master!");startActivity(intent);} else {Toast.makeText(view.getContext(), "跳转失败", Toast.LENGTH_SHORT).show();}}// Action是否允许public static boolean isActionAvailable(Context context, String action) {Intent intent = new Intent(action);return context.getPackageManager().resolveActivity(intent, 0) != null;}
}

有时间可以再试试其他公司的插件化. One by One.

OK, that's all! Enjoy It.

原文地址: http://www.jianshu.com/p/f1217cce93ef

使用DroidPlugin实践应用的插件化相关推荐

  1. Android插件化原理和实践 (一) 之 插件化简介和基本原理简述

    1 插件化简介 Android插件化技术是一种这几年间非常火爆的技术,也是只有在中国才流行起来的技术,这几年间每每开发者大会上几乎都会提起关于插件化技术和相关方向.在国内各大互联网公司无不都有自己的插 ...

  2. Android 插件化总结

    2019独角兽企业重金招聘Python工程师标准>>> 1.Android中插件开发篇总结和概述 2.Android组件化和插件化开发 3.携程Android App插件化和动态加载 ...

  3. Android 插件化原理学习 —— Hook 机制之动态代理

    前言 为了实现 App 的快速迭代更新,基于 H5 Hybrid 的解决方案有很多,由于 webview 本身的性能问题,也随之出现了很多基于 JS 引擎实现的原生渲染的方案,例如 React Nat ...

  4. iOS 上的插件化设计

    ????????关注后回复 "进群" ,拉你进程序员交流群???????? 转自:掘金 ZenonHuang https://juejin.cn/post/697962703724 ...

  5. VirtualAPK:滴滴 Android 插件化的实践之路

    一.前言 在 Android 插件化技术日新月异的今天,开发并落地一款插件化框架到底是简单还是困难,这个问题不同人会有不同的答案.但是我相信,完成一个插件化框架的 Demo 并不是多难的事儿,然而要开 ...

  6. [Android 插件化(二)] DroidPlugin 用法

    1 简介 关于Android插件化可以查看我的前一篇博客:  [Android 插件化(一)] DynamicLoadApk的用法 本篇介绍第二种实现插件化的框架,360公司出品的DroidPlugi ...

  7. 【Android 插件化】DroidPlugin 编译运行 ( DroidPlugin 简介 | 编译 DroidPlugin 官方示例 | 运行 DroidPlugin 官方示例 )

    文章目录 一.DroidPlugin 简介 二.DroidPlugin 编译运行 1.编译 DroidPlugin 官方示例 2.运行 DroidPlugin 官方示例 一.DroidPlugin 简 ...

  8. DroidPlugin插件化开发

    360手机助手使用的 DroidPlugin,它是360手机助手团队在Android系统上实现了一种插件机制.它可以在无需安装.修改的情况下运行APK文件,此机制对改进大型APP的架构,实现多团队协作 ...

  9. 技术期刊 · 路尘终见泰山平 | 微前端及插件化架构在 Wix 的实践;编辑器架构的第二路径;业务中的前端组件化体系……

    蒲公英 · JELLY技术期刊 Vol.47 不想当架构师的程序员不是"合格"的程序员?这一类的言论在很多文章中应该很常见吧,我们需要架构思维,要有抽象能力,要学会分层--需要的太 ...

最新文章

  1. 软件测试--利用正交表设计测试用例
  2. Java反射得到属性的值和设置属性的值
  3. 树莓派如何卸载mysql_树莓派安装MySQL数据库与卸载
  4. 三极管的耐压与hFE之间是什么关系?
  5. MySQL查询获取行号rownum
  6. c盘怎么清理到最干净_C盘快满了不敢乱删,该如何清理?这里给你最详细的方法!...
  7. hprose for java 教程_hprose for java源码分析-4
  8. [html] 写一个搜索框,聚焦时搜索框向左拉长并有动画效果
  9. 线性表操作的基本应用
  10. 轻松看懂机器学习十大常用算法 - 基础知识
  11. 【Python】Matplotlib绘制正余弦曲面图
  12. 口腔行业的隐形冠军,现代牙科集团掘金步入新阶段
  13. linux操作系统和ucos操作系统,嵌入式操作系统ucos与linux比较
  14. java linux 时间戳转时间_Java时间和时间戳的相互转换
  15. keyshot渲染很慢_提高Keyshot逼真渲染的小技巧!
  16. ( )可用来更改计算机系统的设置,2018年职称计算机考试考前冲刺练习及答案(9)...
  17. 电脑公司特供版 GHOST XP SP3 纯净版 Ver1105
  18. 进销存管理系统大全【70个进销存系统】
  19. 使用python将txt格式的数据转换为csv格式,读取csv数据前几行
  20. linux单片机用什么数据库,基于ARM-Linux的SQLite嵌入式数据库的研究 -单片机-电子工程世界网...

热门文章

  1. C# try catch finally 执行
  2. 统计学习:方差分析(ANOVA2)
  3. Linux内存管理之一 分段与分页
  4. 回溯法模板(矩阵中操作)
  5. 【算法】SVM分类精度为0,结果很烂怎么办?
  6. 【Matlab】矩阵中选取任意子矩阵
  7. 云炬Android开发笔记 5-9,10拦截器功能设计与实现
  8. python分片操作_【python原理解析】python中分片的实现原理及使用技巧
  9. python 日志不会按照日期分割_python日志切割保留一个月
  10. html video 设置进度条不可拖动_PHP大文件切割上传并带上进度条功能,不妨一试...