背景

最近客户那边反馈需求希望我司的设备能像三星的机器一样,usb连接电脑的时候默认是mtp模式,同时可以解决电脑查看手机上的文件有时候不一致的问题(也就是手机上创建的新文件或者目录,电脑上不能及时看到)。

需求分解

需求我们分解一下,其实是两个需求。

1.usb连接电脑默认mtp模式

2.实时文件扫描

需求实现思路

一般来说,如果需求可以不动os测实现我们尽量不去动os。

默认mtp模式:我们可以监测插入usb和断开usb,插入usb线的时候就把从充电模式切换到mtp模式。

实时文件扫描:这个需求其实有两种实现,方式一是以切换到mtp模式为trigger,来做一次全盘扫描。方式二是对存储空间进行监视,当有文件create或者delete的时候,去扫描该文件。

1.默认mtp模式

思路:

注册个广播A接受开机广播,广播A中start一个Service,Service动态注册广播来接受"android.hardware.usb.action.USB_STATE",判断好插入usb线的条件,保证在插入usb线后跳到mtp模式。

代码:

private boolean mUsbModeInit=true;IntentFilter filter = new IntentFilter();
filter.addAction("android.hardware.usb.action.USB_STATE");
registerReceiver(mReceiver, filter, null, null);private final BroadcastReceiver mReceiver = new BroadcastReceiver() {public void onReceive(Context context, Intent intent) {if (intent.getAction().equals("android.hardware.usb.action.USB_STATE")){boolean connected = intent.getExtras().getBoolean("connected");boolean configured = intent.getExtras().getBoolean("configured");boolean unlocked = intent.getExtras().getBoolean("unlocked");if(!connected&&!configured){mUsbModeInit=true;}if (connected&&configured&&!unlocked&&mUsbModeInit){UsbManager usbManager = (UsbManager) getSystemService(UsbManager.class);if(Build.VERSION.SDK_INT<Build.VERSION_CODES.O){try {Class<?> clazz=Class.forName("android.hardware.usb.UsbManager");Method method=clazz.getMethod("setCurrentFunction", String.class);method.invoke(usbManager,UsbManager.USB_FUNCTION_MTP);Method method1=clazz.getMethod("setUsbDataUnlocked", boolean.class);method1.invoke(usbManager,true);} catch (Exception e) {e.printStackTrace();}}else {usbManager.setCurrentFunction(UsbManager.USB_FUNCTION_MTP, true);}mUsbModeInit=false;}}
}

分析总结:

我们注册广播来监听"android.hardware.usb.action.USB_STATE",顾名思义,只要usb的state发生变化,系统就会发出该广播。

有兴趣的朋友可以实测一下,当我们插入usb线或断开usb线的时候,该广播会触发多次。这种state change的广播在android系统中有很多,比如蓝牙,wifi的打开和关闭也是一样的。打开和关闭也不仅仅是发了一次广播。

所以,在广播中根据广播携带的内容来做判断就显得至关重要了,毕竟我们只希望我们的代码只执行一次。

源码分析

我们去追一追源码,涉及的相关类有:

frameworks/base/core/java/android/hardware/usb/UsbManager.java

frameworks/base/services/usb/java/com/android/server/usb/UsbDeviceManager.java

frameworks/base/services/core/java/com/android/server/connectivity/Tethering.java

广播是在UsbDeviceManager.java里面发出来的:

private void updateUsbStateBroadcastIfNeeded(boolean configChanged) {// send a sticky broadcast containing current USB stateIntent intent = new Intent(UsbManager.ACTION_USB_STATE);intent.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING| Intent.FLAG_RECEIVER_INCLUDE_BACKGROUND| Intent.FLAG_RECEIVER_FOREGROUND);intent.putExtra(UsbManager.USB_CONNECTED, mConnected);intent.putExtra(UsbManager.USB_HOST_CONNECTED, mHostConnected);intent.putExtra(UsbManager.USB_CONFIGURED, mConfigured);intent.putExtra(UsbManager.USB_DATA_UNLOCKED,isUsbTransferAllowed() && mUsbDataUnlocked);intent.putExtra(UsbManager.USB_CONFIG_CHANGED, configChanged);if (mCurrentFunctions != null) {String[] functions = mCurrentFunctions.split(",");for (int i = 0; i < functions.length; i++) {final String function = functions[i];if (UsbManager.USB_FUNCTION_NONE.equals(function)) {continue;}intent.putExtra(function, true);}}// send broadcast intent only if the USB state has changedif (!isUsbStateChanged(intent) && !configChanged) {if (DEBUG) {Slog.d(TAG, "skip broadcasting " + intent + " extras: " + intent.getExtras());}return;}if (DEBUG) Slog.d(TAG, "broadcasting " + intent + " extras: " + intent.getExtras());mContext.sendStickyBroadcastAsUser(intent, UserHandle.ALL);mBroadcastedIntent = intent;
}

可以看出这个广播携带了很多extra。而且这个广播是黏性广播,这意味着你在注册broadcastReceiver后马上就能收到一次。所以即使我们插着usb线开机,没有插入usb线的操作去trigger,我们注册广播后依然可以把usb模式转换为mtp模式。

关于extras的解释我们在Tethering.java可以一探究竟:

它的handleUsbAction方法中有注释提到:

            // There are three types of ACTION_USB_STATE:////     - DISCONNECTED (USB_CONNECTED and USB_CONFIGURED are 0)//       Meaning: USB connection has ended either because of//       software reset or hard unplug.////     - CONNECTED (USB_CONNECTED is 1, USB_CONFIGURED is 0)//       Meaning: the first stage of USB protocol handshake has//       occurred but it is not complete.////     - CONFIGURED (USB_CONNECTED and USB_CONFIGURED are 1)//       Meaning: the USB handshake is completely done and all the//       functions are ready to use.

这样就一目了然了。这里我们比较关注的是DISCONNECTED和CONFIGURED。

                boolean connected = intent.getExtras().getBoolean("connected");boolean configured = intent.getExtras().getBoolean("configured");boolean unlocked = intent.getExtras().getBoolean("unlocked");

connected和configured配合可以表明DISCONNECTED和CONFIGURED状态。

Unlocked可以表明是否是mtp模式。

Android O与N的适配

代码里面还有一个注意点是android不同版本间的适配,android O上是用的

UsbManager usbManager = (UsbManager) getSystemService(UsbManager.class);
usbManager.setCurrentFunction(UsbManager.USB_FUNCTION_MTP, true);

而android N上面则是

UsbManager usbManager = (UsbManager) getSystemService(UsbManager.class);
usbManager.setCurrentFunction(UsbManager.USB_FUNCTION_MTP);
usbManager. setUsbDataUnlocked (true);

因为O上api变了,所以直接调用会编译不过。因为我这边android studio的project的compileSdkVersion是26,也就是android O。所以N的api只能用反射来做了。

其它注意点

代码里面我们需要添加一个标志位,保证只会在插入usb线的时候切换成mtp模式。如果没有mUsbModeInit这个标志位,我们在连接usb的情况下,手动切换到充电模式也会自己跳到mtp模式,这显然不是我们想要的。

实时文件扫描:Mtp模式触发全盘扫描

思路:

跟上文一样,我们监听usb state的变化,当判断切换到mtp模式的时候,触发一次全盘扫描。可能会有人疑惑是否需要等扫描结束后再切换到mtp模式在电脑上显示出设备的文件系统,实测不用。

其实网上比较通用的扫描文件的api一个是发送广播

    public void scanPath(String path) {mIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);mIntent.setData(Uri.fromFile(new File(path)));mContext.sendBroadcast(mIntent);
}

一个是用MediaScannerConnection

    public void mediaScan(File file) {MediaScannerConnection.scanFile(this,new String[]{file.getAbsolutePath()}, null,new MediaScannerConnection.OnScanCompletedListener() {@Overridepublic void onScanCompleted(String path, Uri uri) {Log.v("MediaScanWork", "file " + path+ " was scanned seccessfully: " + uri);}});
}

但是这两个方式都只能对一个文件或者多个文件进行扫描。如果想要对指定目录扫描或storage/emulated/0进行扫描就做不到了,除非咱们遍历所有文件去扫描。我们的优先目标肯定是最好有现成的简洁的api来调。

网上还有提到ACTION_MEDIA_MOUNTED,这个action在android O里面可谓名存实亡,并没有在源码里面找到相关的收这个广播的实际处理代码。

而ACTION_MEDIA_SCANNER_SCAN_DIR,在高版本的源码里面根本没这个action。

所以我们只能另辟蹊径,很偶然的机会,我在测试的过程中发现设备上自带的文件管理器(高通平台)创建的文件夹可以马上同步到电脑上,而我们使用File类的mkdir方法创建的文件夹却不能马上同步到电脑上。

追了下文件管理器的源码:

packages\apps\CMFileManager\src\com\cyanogenmod\filemanager\util\CommandHelper.java

createDirectory方法中有doMediaScan(context),把这个方法移到我们的代码中,果然生效了。

代码:

private boolean mUsbModeInit=true;IntentFilter filter = new IntentFilter();
filter.addAction("android.hardware.usb.action.USB_STATE");
registerReceiver(mReceiver, filter, null, null);private final BroadcastReceiver mReceiver = new BroadcastReceiver() {public void onReceive(Context context, Intent intent) {if (intent.getAction().equals("android.hardware.usb.action.USB_STATE")){boolean connected = intent.getExtras().getBoolean("connected");boolean configured = intent.getExtras().getBoolean("configured");boolean unlocked = intent.getExtras().getBoolean("unlocked");if(!connected&&!configured){mUsbModeInit=true;}if (connected&&configured&&unlocked&&mUsbModeInit){doMediaScan(mContext);mUsbModeInit=false;}}
}
public  void doMediaScan(Context context) {Bundle args = new Bundle();args.putString("volume", "external");Intent startScan = new Intent();startScan.putExtras(args);startScan.setComponent(new ComponentName("com.android.providers.media","com.android.providers.media.MediaScannerService"));context.startService(startScan);
}

分析总结:

源码分析:

我们去追一下doMediaScan这个方法中的MediaScannerService具体是怎么进行全盘扫描的。

packages\providers\MediaProvider\src\com\android\providers\media\MediaScannerService.java

    public int onStartCommand(Intent intent, int flags, int startId) {while (mServiceHandler == null) {synchronized (this) {try {wait(100);} catch (InterruptedException e) {}}}if (intent == null) {Log.e(TAG, "Intent is null in onStartCommand: ",new NullPointerException());return Service.START_NOT_STICKY;}Message msg = mServiceHandler.obtainMessage();msg.arg1 = startId;msg.obj = intent.getExtras();mServiceHandler.sendMessage(msg);// Try again later if we are killed before we can finish scanning.return Service.START_REDELIVER_INTENT;}

onStartCommand里面把传进来的intent拿到,拿到intent的数据包装成一个Message,然后丢到消息队列里面去

   private final class ServiceHandler extends Handler {@Overridepublic void handleMessage(Message msg) {
…..
else if (MediaProvider.EXTERNAL_VOLUME.equals(volume)) {// scan external storage volumesif (getSystemService(UserManager.class).isDemoUser()) {directories = ArrayUtils.appendElement(String.class,mExternalStoragePaths,Environment.getDataPreloadsMediaDirectory().getAbsolutePath());} else {directories = mExternalStoragePaths;}
}if (directories != null) {if (false) Log.d(TAG, "start scanning volume " + volume + ": "+ Arrays.toString(directories));scan(directories, volume);if (false) Log.d(TAG, "done scanning volume " + volume);
}
…
}

这里有兴趣的人,可以去改下log,然后可以看到scan的过程的耗时。

核心是scan方法

    private void scan(String[] directories, String volumeName) {Uri uri = Uri.parse("file://" + directories[0]);// don't sleep while scanningmWakeLock.acquire();try {ContentValues values = new ContentValues();values.put(MediaStore.MEDIA_SCANNER_VOLUME, volumeName);Uri scanUri = getContentResolver().insert(MediaStore.getMediaScannerUri(), values);sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, uri));try {if (volumeName.equals(MediaProvider.EXTERNAL_VOLUME)) {openDatabase(volumeName);}try (MediaScanner scanner = new MediaScanner(this, volumeName)) {scanner.scanDirectories(directories);}} catch (Exception e) {Log.e(TAG, "exception in MediaScanner.scan()", e);}getContentResolver().delete(scanUri, null, null);} finally {sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, uri));mWakeLock.release();}
}

Scan方法里面还持有了wakelock。原来是调用的MediaScanner对象的scanDirectories方法。MediaScanner类在以下路径

frameworks/base/media/java/android/media/MediaScanner.java

到这里,我们就暂时不用追了也真相大白了。

实时文件扫描:全盘监听

思路:

使用FileObserver监听storage/emulated/0这个目录,当有文件create或者delete,就触发一次扫描该文件。

代码:

private RecursiveFileObserver mRecursiveFileObserver;
public void onCreate() {mRecursiveFileObserver=new RecursiveFileObserver(FILE_OBSERVER_DIR,FileObserver.CREATE | FileObserver.DELETE,this);mRecursiveFileObserver.startWatching();
}
public void onDestroy() {mRecursiveFileObserver.stopWatching();
}
package com.honeywell.ezservice.utils;import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Bundle;
import android.os.FileObserver;
import android.util.ArrayMap;
import android.util.Log;import java.io.File;
import java.io.IOException;
import java.util.Iterator;
import java.util.Map;
import java.util.Stack;public class RecursiveFileObserver extends FileObserver {Map<String, SingleFileObserver> mObservers;String mPath;int mMask;Context mContext;Intent mIntent;public RecursiveFileObserver(String path, Context context) {this(path, ALL_EVENTS, context);}public RecursiveFileObserver(String path, int mask, Context context) {super(path, mask);mPath = path;mMask = mask;mContext = context.getApplicationContext();}public void scanPath(String path) {mIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);mIntent.setData(Uri.fromFile(new File(path)));mContext.sendBroadcast(mIntent);}public void scanEmptyFolder(final Context context, File targetFile) {final File dummy = new File(targetFile, "init");try {dummy.createNewFile();} catch (IOException e) {e.printStackTrace();}MediaScannerConnection.scanFile(context, new String[]{dummy.getAbsolutePath()}, null, new MediaScannerConnection.OnScanCompletedListener() {@Overridepublic void onScanCompleted(String s, Uri uri) {// delete file and scan again (because file should not be displayed)dummy.delete();MediaScannerConnection.scanFile(context, new String[]{dummy.getAbsolutePath()}, null, null);}});}@Overridepublic void startWatching() {if (mObservers != null)return;mObservers = new ArrayMap<>();Stack stack = new Stack();stack.push(mPath);while (!stack.isEmpty()) {String temp = (String) stack.pop();mObservers.put(temp, new SingleFileObserver(temp, mMask));File path = new File(temp);File[] files = path.listFiles();if (null == files)continue;for (File f : files) {// 递归监听目录if (f.isDirectory() && !f.getName().equals(".") && !f.getName().equals("..")) {stack.push(f.getAbsolutePath());}}}Iterator<String> iterator = mObservers.keySet().iterator();while (iterator.hasNext()) {String key = iterator.next();mObservers.get(key).startWatching();}}@Overridepublic void stopWatching() {if (mObservers == null)return;Iterator<String> iterator = mObservers.keySet().iterator();while (iterator.hasNext()) {String key = iterator.next();mObservers.get(key).stopWatching();}mObservers.clear();mObservers = null;}@Overridepublic void onEvent(int event, String path) {int el = event & FileObserver.ALL_EVENTS;switch (el) {case FileObserver.ATTRIB:Log.i("RecursiveFileObserver", "ATTRIB: " + path);break;case FileObserver.CREATE:File file = new File(path);if (file.isDirectory()) {Stack stack = new Stack();stack.push(path);while (!stack.isEmpty()) {String temp = (String) stack.pop();if (mObservers.containsKey(temp)) {continue;} else {SingleFileObserver sfo = new SingleFileObserver(temp, mMask);sfo.startWatching();mObservers.put(temp, sfo);}File tempPath = new File(temp);File[] files = tempPath.listFiles();if (null == files)continue;for (File f : files) {// 递归监听目录if (f.isDirectory() && !f.getName().equals(".") && !f.getName().equals("..")) {stack.push(f.getAbsolutePath());}}}}/*分几种情况1.已监听目录创建文件2.创建未监听目录3.创建未监听目录并同时创建文件(代码创建很快,这时候监听目录晚了,文件创建会监听不到)*///potter addLog.i("RecursiveFileObserver", "CREATE: " + path);//case 1 beginif (file.isFile()) {scanPath(path);}//case 1 end//如果是用代码创建的一个长目录带文件,而且目录不存在,这时候监听的时候,文件已经创建结束了,就会导致没有监听到,所以得遍历了scan一次if (file.isDirectory()) {//case 3 beginFile[] files = file.listFiles();for (File f : files) {scanPath(f.getAbsolutePath());}//case 3 end//case 2 beginif (files.length == 0) {scanEmptyFolder(mContext, file);}//case 2 end}//potter endbreak;case FileObserver.DELETE:Log.i("RecursiveFileObserver", "DELETE: " + path);break;case FileObserver.DELETE_SELF:Log.i("RecursiveFileObserver", "DELETE_SELF: " + path);break;case FileObserver.MODIFY:Log.i("RecursiveFileObserver", "MODIFY: " + path);break;case FileObserver.MOVE_SELF:Log.i("RecursiveFileObserver", "MOVE_SELF: " + path);break;case FileObserver.MOVED_FROM:Log.i("RecursiveFileObserver", "MOVED_FROM: " + path);break;case FileObserver.MOVED_TO:Log.i("RecursiveFileObserver", "MOVED_TO: " + path);break;}}class SingleFileObserver extends FileObserver {String mPath;public SingleFileObserver(String path) {this(path, ALL_EVENTS);mPath = path;}public SingleFileObserver(String path, int mask) {super(path, mask);mPath = path;}@Overridepublic void onEvent(int event, String path) {if (path != null) {String newPath = mPath + "/" + path;RecursiveFileObserver.this.onEvent(event, newPath);}}}
}

分析总结:

代码里还是有一些需要关注的点的。

一个是FileObserver进行监听的时候,只能监听指定目录,它的子目录是监听不到了。

这里参考了https://www.jianshu.com/p/65fb687d3458,它的思路是对已有目录迭代进行监听,并对新建的目录也进行了监听。

然后我们是前面提到的ACTION_MEDIA_SCANNER_SCAN_FILE发广播或者MediaScannerConnection来做扫描,这两种方法都只是针对文件,对于文件夹达不到效果的,即使你传入的参数是一个文件夹,你连接电脑后看到的却是一个同名的文件。

举个例子:

Case1:已有目录,创建a1文件。传入a1文件路径,可以成功扫描。

Case2:创建新的目录folder2,传入folder2路径,电脑上识别的是一个叫folder2的文件,而不是文件夹。

Case3:创建新的目录folder1,新目录里面创建a2文件,传入b文件路径,可以成功扫描。Folder也能成功识别。

针对这三种情况,代码里面进行了处理和备注。

Case1,直接发广播或者调用MediaScannerConnection就ok。

Case2,我们采用创建一个文件,扫描结束后再删除的方式。这里参考了https://stackoverflow.com/questions/32637993/android-scanfile-on-empty-directory

Case3,这里要分两个情况,如果是手动在文件管理类的apk里面新增目录,然后目录里面添加文件是能够监听到这个文件的create的。但是如果是代码创建的目录和文件,会出现目录创建ok,文件创建ok,这时候新的目录的监听才加上,导致新增的文件的create没监听到。所以我们在发现新目录后就做下遍历操作来扫描下就可以解决这个bug。

总结

对于默认mtp模式,没什么好说的。用上面提到的方法即可。

而实时文件扫描的两种方式:

有一些区别,mtp模式触发全盘扫描就稍微路子正一点,因为已知的有的文件管理器就是采用的doMediaScan来做的。

而全盘监听呢,路子有点野,理论上是优于mtp模式触发的,因为理论上采用全盘监听一个文件只会在创建的时候扫描一次,而mtp模式触发则可能多次扫描这个文件。但是监听的开销又不好评估了,毕竟现在用来测试的机器并没有太多迭代的目录,文件个数也不算多。

全盘监听还比mtp模式触发有一个优势就是连接电脑并且已经是mtp模式下,如果这时候创建删除文件,全盘监听的方式是可以反映到电脑上去的,而mtp模式触发则做不到。

综上,我个人项目中使用的还是mtp模式触发的方式。

Ok,大功告成!希望对各位有用。

Android N Android O 默认MTP模式 实时文件扫描相关推荐

  1. Android6.0 usb默认MTP模式的修改方法

    Android6.0 usb默认MTP模式的修改方法 在6.0以前的系统 只需要修改默认的 persist.sys.usb.config 的值就可以了, 但是6.0,无论你怎能修改persist.sy ...

  2. 【Android 应用开发】Android 返回堆栈管理 ( 默认启动模式 | 栈顶复用启动模式 | 栈内复用启动模式 | 单实例启动模式 | CLEAR_TOP 标识 )

    文章目录 I . 默认启动模式 ( standard ) II . 栈顶复用启动模式 ( singleTop ) III . 栈内复用启动模式 ( singleTask ) IV . 单实例启动模式 ...

  3. android usb 默认mtp,usb修改为默认MTP模式

    1.packages/apps/Settings / src/com/android/settings/deviceinfo/UsbBackend.java getUsbDataMode() 中  / ...

  4. Android 11源码 Framework修改默认usb连接模式为MTP模式

    Android 11源码 Framework修改默认usb连接模式为MTP模式 Android 11源码 Framework修改默认usb连接模式为MTP模式 修改Framework层源码 编译修改后 ...

  5. Android 连接USB默认选中MTP模式

    Android 连接USB默认选中MTP模式 需求分析 Android系统默认连接USB会显示:正在通过USB为此设备充电,并且无法在电脑查看存储内容.需要实现的效果:Android 连接USB默认选 ...

  6. MTP模式与USB存储模式(MTP in Android)

    转载:http://bbs.meizu.cn/thread-4747416-1-1.html MTP in Android MTP的全称是Media Transfer Protocol(媒体传输协议) ...

  7. android手机(平板)下载文件后,在文件管理软件中可以看到,通过mtp模式连接电脑后,无法在电脑上看到

    Android软件进行下载文件后,可以在手机或平板的文件管理软件中进行查看,并且正常进行,但是连接电脑后,在电脑上却找不到该文件. 原因: android手机或平板通过mtp模式与电脑进行连接时,会出 ...

  8. Win10下不能识别Android的MTP模式

    背景描述: 测试华为手机,选择MTP模式,首次电脑设备管理器,会出现"其他设备 – MTP设备"的标识,但持续5~10s就会自动识别成"便携设备 – 华为手机" ...

  9. Android默认设置MTP模式

    这里主要以高通MSM8953为例 –a device/qcom/msm8963_64/system.prop ++b device/qcom/msm8963_64/system.prop #Set c ...

最新文章

  1. 转载知乎上的一篇:“ 面向对象编程的弊端是什么?”
  2. 由一个园友因为上传漏洞导致网站被攻破而得到的教训
  3. TensorFlow学习入门
  4. 如何在.NET Core控制台程序中使用依赖注入
  5. Android Studio 日志工具
  6. 【ZOJ - 3703】Happy Programming Contest(带优先级的01背包,贪心背包)
  7. easyui form 提交
  8. linux下安装mysql数据库
  9. python获得字符出现频率,并用字典保存;获得字典最大value对应的key值
  10. Apache 配置两个域名匹配的文件夹和配置多个Web站点
  11. OpenShift Security (6) - 用网络图可视化网络访问策略
  12. webpack打包优化之外部扩展externals的实际应用
  13. 数据结构与算法之霍夫曼编码解码实现
  14. [老生常谈] Linux 下读取windows共享目录
  15. VSCode在文件顶部添加作者,时间和注释等信息
  16. 算法设计和数据结构学习_2(常见排序算法思想)
  17. android gdb gdbserver
  18. Java 冒泡排序的使用
  19. UPDATE更新数据库数据详解
  20. K8s 中 iptables 和ipvs 的理解

热门文章

  1. Docker 使用快速入门
  2. 关于使用fluxion工具破解wifi密码的详细教程
  3. dp专题-cf 711c
  4. C++之getch(),getche(),getchar()的区别
  5. ios tableView那些事 (九) tableview的删除
  6. Ubuntu14.04安装搜狗拼音输入法(中文输入法)
  7. ROS学习——rotors仿真下载与运行
  8. 003 大数据4V特征
  9. 问题来了,大数据的特性究竟有多少个V?
  10. 基于nonebot2+go-cqhttp的QQ机器人构建(1)机器人搭建