Android N系列适配---FileProvider

Android N系列适配---FileProvider

Android 7.0的适配,主要包含方面:

  • Android 7.0 主要功能的diff---介绍主要Android7.0功能以及行为变更
  • Android 7.0 最重要的一环适配---FileProvider的适配
  • Android 7.0 对常规三方的影响---UIL为例

Android 7.0 功能diff---详细介绍Android7.0拥有的功能

  1. 多窗口支持:
    1. 用户可以一次在屏幕上打开两个应用,或者处于分屏模式时一个应用位于另一个应用之上。 用户可以通过拖动两个应用之间的分隔线来调整应用。
    2. 在 Android TV 设备上,应用可以将自身置于画中画模式,从而让它们可以在用户浏览或与其他应用交互时继续显示内容。
    3. 可以指定app Activity大小,防止用户调整到该尺寸以下
  2. 通知增强功能:
    1. 模板更新
      1. 少量代码调整,即可使用新的通知模版开发
    2. 消息样式更新
      1. MessageStyle 类,可配置消息,会话标题,以及内容视图
    3. 捆绑通知
      1. 系统可以将消息按一定规律给组合,如消息主题,用户可以适当的进行Dismiss和Archive等操作
    4. 直接回复
      1. 即时通讯应用,支持用户直接在通知界面中快速回复消息
    5. 自定义视图
      1. 两个新的 API,使用自定义视图时可以充分利用系统装饰元素,如通知标题和操作
  3. Project Svelte 后台优化:
    1. 删除了三个常用隐式广播,继续扩展 JobScheduler 和 GCMNetworkManager
  4. apk signature scheme V2
    1. 新的应用签名方案
    2. Android Studio 2.2 和 Android Gradle 2.2 插件会使用 APK
  5. 附上官方链接:
    https://developer.android.com/about/versions/nougat/android-7.0.html#multi-window_support

行为变更和影响
  1. 当设备处于低电耗,首先会限制,关闭应用网络访问,推迟作业和同步,一定时间后,会对除去PowerManager.WakeLock和Alarmmanager闹铃,GPS和WIFI扫描以外的进行低电耗限制
  2. 后台优化,删除了三个隐式广播,如果app用到了,需要及时的解除关系
  3. 应用间共享文件的修改
  4. 无障碍改进,屏幕缩放,设置向导中视觉设置
  5. 附上官方链接:
    https://developer.android.com/about/versions/nougat/android-7.0-changes.html

Android 7.0 FileProvider的适配

  • 是什么
    • 关于安卓7.0的适配,其中变更最大的就是FileProvider,关于FileProvider并不是最新出来的东西,而是以前就已经存在,由于Android的安全机制 ,一个进程默认不能影响另外一个进程的,如读取私有数据 。 那么对于进程间的文件的共享 ,出于安全考虑,用FileProvider。FileProvider会基于manifest中的定义定义的一个xml文件(xml目录 下),为所有定义的文件生成content URIs,这样外部的应用在没有权限的情况下,可以通过授予临时权限的content uri,读取相应的文件。
      FileProvider是v4 support中的类 , 就继承ContentProvider。也就是说content:// Uri 代替了 file:/// Uri. 在Android7.0时候,为了安全,谷歌把它作为了一个强制使用而已。针对file://URI,需要通过FileProvider来转换成content://URI进行访问。
  • 限制
    • 那么会有人要问,是否所有需要从本地存储的东西都会被限制呢,其实不然,谷歌做这项规定主要是针对,包含文件 URI 的 Intent 离开你的应用,换句话说,如果你的Intent中用到了Uri,这个时候你就需要提防一下了,比如说,你使用到了图片裁剪等功能。
  • 怎么做
    • 第一步:

      • 全局找出项目中,需要修改的地方,如下:
      • Uri.parse、Uri.fromFile、file://、content://、Context.getFilesDir()、Environment.getExternalStorageDirectory()、getCacheDir()以及最终要的intent.setDataAndType(为什么需要找这个,因为这个会携带uri进行传递,这个是重头戏)
    • 第二步:

      • 找到罪魁祸首之后,需要按照步骤适配了,依次顺序是,清单文件的修改,资源文件的修改,以及Java代码中的修改
    • 第三步:

      • 清单文件的修改---清单文件中,添加provider标签即可

          <provider
              android:exported="false"
              android:grantUriPermissions="true"
              android:authorities="com.***.fileprovider"
              android:name="android.support.v4.content.FileProvider">
          <meta-data
              android:name="android.support.FILE_PROVIDER_PATHS"
              android:resource="@xml/file_paths"
              ></meta-data>
          </provider> 
        
    • 第四步:

      • 创建res/xml/filepaths.xml文件

          < paths xmlns:android="http://schemas.android.com/apk/res/android">
              <external-path path="" name="external-path" />
              <files-path path="" name="files_path" />
              <cache-path path="" name="cache-path" />
          </paths>
        
      • 在这个文件中,为每个目录添加一个XML元素指定目录。paths 可以添加多个子路径:< files-path> 分享app内部的存储;< external-path> 分享外部的存储;< cache-path> 分享内部缓存目录。

      • < files-path >
        代表目录为:Context.getFilesDir()

      • <external-path>
        代表目录为:Environment.getExternalStorageDirectory()

      • <cache-path>
        代表目录为:getCacheDir()

      • 那么又存在了一个问题,国内由于rom众多,会产生各种路径,比如华为的/system/media/,以及外置sdcard,像此类路径该如何适配呢?

      • < root-path path="" name="root-path" />

      • 在这里又有人要问了,为什么要加root_path就管用,下面我们就一起再追踪一下源码

      • 我们打开FileProvider的源码

          public class FileProvider extends ContentProvider
        
      • 开篇就能看见几个变量

          private static final String TAG_ROOT_PATH = "root-path";
          private static final String TAG_FILES_PATH = "files-path";
          private static final String TAG_CACHE_PATH = "cache-path";
          private static final String TAG_EXTERNAL = "external-path";
        
      • 里面有个重要方法parsePathStrategy,从xml我们定义临时授权的路径file_paths.xml中,解析以及对比路径

          while ((type = in.next()) != END_DOCUMENT) {
          if (type == START_TAG) {
          final String tag = in.getName();
        
          final String name = in.getAttributeValue(null, ATTR_NAME);
          String path = in.getAttributeValue(null, ATTR_PATH);
        
          File target = null;
          if (TAG_ROOT_PATH.equals(tag)) {
              target = buildPath(DEVICE_ROOT, path);
          } else if (TAG_FILES_PATH.equals(tag)) {
              target = buildPath(context.getFilesDir(), path);
          } else if (TAG_CACHE_PATH.equals(tag)) {
              target = buildPath(context.getCacheDir(), path);
          } else if (TAG_EXTERNAL.equals(tag)) {
              target = buildPath(Environment.getExternalStorageDirectory(), path);
          }
        
          if (target != null) {
              strat.addRoot(name, target);
          }
          }
          }
        
      • buildPath(DEVICE_ROOT, path)这个方法甚是晃眼

          private static final File DEVICE_ROOT = new File("/");  
        
      • 到这里,我们应该就明白了,这个root代表的是根路径,如果还不明白,我们可以进入adb试一下

          MacBook-Pro:~ baidu$ adb shell
          bullhead:/ $ cd /
          bullhead:/ $ ls 
        
      • 然后出现的路径是

          acct    config dev      mnt  property_contexts sbin    sys    
          cache   d      etc      oem  res               sdcard  system 
          charger data   firmware proc root              storage vendor 
        
      • 然后我们就看到了熟悉的system 以及sdcard等,到这里我们就彻底明白,root_path是为我们的根路径进行了临时授权,如果要访问系统system以及外置sdcard的话,在这里将得到授权。

      • 那么又有个问题,如果我写了root_path的话,其他的file_path等是不是就不用写了呢,答案是可以的,已经试验,确实可以。不过反过来想,如果每次都对根路径进行授权,那么这个FileProvider是不是意义就不大了呢,相当于安全性还是没有防护,所以,谷歌的良苦用心,我们还需要理解,大家授权的时候,还是要把所有的路径,能详细的,尽量详细一下。

      • 附:至于为何path="",这里要写空,原因是空表示根目录都可以进行查找,当然如果路径确定,可以写成path="images/",这表示直接适配了images这个文件夹,也就是可以在这个文件夹下查找,而在这个文件夹外,照旧会报错。后面尾随的这个name,则可以随意写,当FileProvider转换路径的时候,就会用此name代替,比如
        content://com.***.fileprovider/myimages/default_image.jpg

    • 第五步:

      • 在java代码中使用
      •   //得到缓存路径的Uri
          Uri contentUri = FileProvider.getUriForFile(getActivity(), "com.***.fileprovider", file);
          //获取壁纸
          Intent intent = WallpaperManager.getInstance(getActivity()).getCropAndSetWallpaperIntent(contentUri);
          //开启一个Activity显示图片,可以将图片设置为壁纸。调用的是系统的壁纸管理。
          getActivity().startActivityForResult(intent, ViewerActivity.REQUEST_CODE_SET_WALLPAPER);
        
      • 这样是否大功告成???

      • java中使用,需要的权限,intent携带的读写权限

          intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
          intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        
      • 你以为这样就真的完事儿了?

      • 在适配过程中,发现有时候addFlag并不能完全的拥有权限,需要grantUriPermission获取权限

          context.grantUriPermission(packageName, uri,
              Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
        
      • 附上福利工具类

          /**
              * Android N 适配工具类
          */
          public class NougatTools {
          /**
          * 将普通uri转化成适应7.0的content://形式  针对文件格式
          *
          * @param context    上下文
          * @param file       文件路径
          * @param intent     intent
          * @param type       图片或者文件,0表示图片,1表示文件
          * @param intentType intent.setDataAndType
          * @return
          */
          public static Intent formatFileProviderIntent(
          Context context, File file, Intent intent, String intentType) {
        
          Uri uri = FileProvider.getUriForFile(context, GlobalDef.nougatFileProvider, file);
          // 表示文件类型
          intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
          intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
          intent.setDataAndType(uri, intentType);
        
          return intent;
          }
        
          /**
          * 将普通uri转化成适应7.0的content://形式  针对图片格式
          *
          * @param context    上下文
          * @param file       文件路径
          * @param intent     intent
          * @param intentType intent.setDataAndType
          * @return
          */
          public static Intent formatFileProviderPicIntent(
          Context context, File file, Intent intent) {
        
              Uri uri = FileProvider.getUriForFile(context, GlobalDef.nougatFileProvider, file);
              List<ResolveInfo> resInfoList = context.getPackageManager().queryIntentActivities(
          intent, PackageManager.MATCH_DEFAULT_ONLY);
          for (ResolveInfo resolveInfo : resInfoList) {
              String packageName = resolveInfo.activityInfo.packageName;
              context.grantUriPermission(packageName, uri,
              Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
          }
          // 表示图片类型
              intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
              return intent;
          }
          /**
          * 将普通uri转化成适应7.0的content://形式
          *
          * @return
          */
          public static Uri formatFileProviderUri(Context context, File file) {
              Uri uri = FileProvider.getUriForFile(context, GlobalDef.nougatFileProvider, file);
              return uri;
              }
          }
        

Android 7.0对三方工具的影响

UIL(Universal-Image-Loader)为例

关于imageloader适配,加载了本地图片,竟然没有问题

    final ImageView imageView = (ImageView) LayoutInflater.from(context).inflate(R.layout.view_banner, null);
   String imageUri = "/mnt/sdcard/image.png";
   ImageLoader.getInstance().displayImage("file://"+imageUri, imageView);
  1. 如果想找到为何没有影响,需要读imageloader源码,直接从imageloader中的加载图片displayImage方法入手

     public void displayImage(String uri, ImageAware imageAware, DisplayImageOptions options,
         ImageLoadingListener listener, ImageLoadingProgressListener progressListener) 
    
  2. 找到bmp != null && !bmp.isRecycled()判断,如果没有从本地找到或者被回收掉了的话,直接走LoadAndDisplayImageTask,去加载图片

     Bitmap bmp = configuration.memoryCache.get(memoryCacheKey);
     if (bmp != null && !bmp.isRecycled()) {
         L.d(LOG_LOAD_IMAGE_FROM_MEMORY_CACHE, memoryCacheKey);
    
         if (options.shouldPostProcess()) {
             ImageLoadingInfo imageLoadingInfo = new ImageLoadingInfo(uri, imageAware, targetSize, memoryCacheKey,
                     options, listener, progressListener, engine.getLockForUri(uri));
             ProcessAndDisplayImageTask displayTask = new ProcessAndDisplayImageTask(engine, bmp, imageLoadingInfo,
                     defineHandler(options));
             if (options.isSyncLoading()) {
                 displayTask.run();
             } else {
                 engine.submit(displayTask);
             }
         } else {
             options.getDisplayer().display(bmp, imageAware, LoadedFrom.MEMORY_CACHE);
             listener.onLoadingComplete(uri, imageAware.getWrappedView(), bmp);
         }
     } else {
         if (options.shouldShowImageOnLoading()) {
             imageAware.setImageDrawable(options.getImageOnLoading(configuration.resources));
         } else if (options.isResetViewBeforeLoading()) {
             imageAware.setImageDrawable(null);
         }
    
         ImageLoadingInfo imageLoadingInfo = new ImageLoadingInfo(uri, imageAware, targetSize, memoryCacheKey,
                 options, listener, progressListener, engine.getLockForUri(uri));
         LoadAndDisplayImageTask displayTask = new LoadAndDisplayImageTask(engine, imageLoadingInfo,
                 defineHandler(options));
         if (options.isSyncLoading()) {
             displayTask.run();
         } else {
             engine.submit(displayTask);
         }
     }
    
  3. 在LoadAndDisplayImageTask的run方法中,会判断是否bitmap为空,这样的话,就会尝试load Bitmap

     if (bmp == null || bmp.isRecycled()) {
             bmp = tryLoadBitmap();
    
  4. 这里才是加载图片的关键,首先去判断磁盘是否存在图片,如果存在,则直接从磁盘加载图片,如果本地没有,则取网络获取图片。

     private Bitmap tryLoadBitmap() throws TaskCancelledException {
     Bitmap bitmap = null;
     try {
         File imageFile = configuration.diskCache.get(uri);
         if (imageFile != null && imageFile.exists() && imageFile.length() > 0) {
             L.d(LOG_LOAD_IMAGE_FROM_DISK_CACHE, memoryCacheKey);
             loadedFrom = LoadedFrom.DISC_CACHE;
    
             checkTaskNotActual();
             bitmap = decodeImage(Scheme.FILE.wrap(imageFile.getAbsolutePath()));
         }
         if (bitmap == null || bitmap.getWidth() <= 0 || bitmap.getHeight() <= 0) {
             L.d(LOG_LOAD_IMAGE_FROM_NETWORK, memoryCacheKey);
             loadedFrom = LoadedFrom.NETWORK;
    
             String imageUriForDecoding = uri;
             if (options.isCacheOnDisk() && tryCacheImageOnDisk()) {
                 imageFile = configuration.diskCache.get(uri);
                 if (imageFile != null) {
                     imageUriForDecoding = Scheme.FILE.wrap(imageFile.getAbsolutePath());
                 }
             }
    
             checkTaskNotActual();
             bitmap = decodeImage(imageUriForDecoding);
    
             if (bitmap == null || bitmap.getWidth() <= 0 || bitmap.getHeight() <= 0) {
                 fireFailEvent(FailType.DECODING_ERROR, null);
             }
         }
     } catch (IllegalStateException e) {
         fireFailEvent(FailType.NETWORK_DENIED, null);
     } catch (TaskCancelledException e) {
         throw e;
     } catch (IOException e) {
         L.e(e);
         fireFailEvent(FailType.IO_ERROR, e);
     } catch (OutOfMemoryError e) {
         L.e(e);
         fireFailEvent(FailType.OUT_OF_MEMORY, e);
     } catch (Throwable e) {
         L.e(e);
         fireFailEvent(FailType.UNKNOWN, e);
     }
     return bitmap;
     }
    
  5. 首次进入肯定是bitmap是空的,找到tryCacheImageOnDisk方法

     private boolean tryCacheImageOnDisk() throws TaskCancelledException {
         L.d(LOG_CACHE_IMAGE_ON_DISK, memoryCacheKey);
    
         boolean loaded;
         try {
             loaded = downloadImage();
             if (loaded) {
                 int width = configuration.maxImageWidthForDiskCache;
                 int height = configuration.maxImageHeightForDiskCache;
                 if (width > 0 || height > 0) {
                     L.d(LOG_RESIZE_CACHED_IMAGE_FILE, memoryCacheKey);
                     resizeAndSaveImage(width, height); // TODO : process boolean result
                 }
             }
         } catch (IOException e) {
             L.e(e);
             loaded = false;
         }
         return loaded;
         }
    
  6. 里面清晰的可以看见,有个downloadImage方法

     private boolean downloadImage() throws IOException {
         InputStream is = getDownloader().getStream(uri, options.getExtraForDownloader());
         if (is == null) {
             L.e(ERROR_NO_IMAGE_STREAM, memoryCacheKey);
             return false;
         } else {
             try {
                 return configuration.diskCache.save(uri, is, this);
             } finally {
                 IoUtils.closeSilently(is);
             }
         }
     }
    
  7. downloadImage方法中,获取到了一个Downloader,通过uri获取流

  8. 看看downloader是啥,有个子类BaseImageDownloader,看里面的getStream方法

     public InputStream getStream(String imageUri, Object extra) throws IOException {
         switch (Scheme.ofUri(imageUri)) {
             case HTTP:
             case HTTPS:
                 return getStreamFromNetwork(imageUri, extra);
             case FILE:
                 return getStreamFromFile(imageUri, extra);
             case CONTENT:
                 return getStreamFromContent(imageUri, extra);
             case ASSETS:
                 return getStreamFromAssets(imageUri, extra);
             case DRAWABLE:
                 return getStreamFromDrawable(imageUri, extra);
             case UNKNOWN:
             default:
                 return getStreamFromOtherSource(imageUri, extra);
         }
     }
    
  9. 那么问题就来了,我们传入的是file://前缀,会最终到downloader中获取stream,继续看看getStreamFromFile

     protected InputStream getStreamFromFile(String imageUri, Object extra) throws IOException {
         String filePath = Scheme.FILE.crop(imageUri);
         if (isVideoFileUri(imageUri)) {
             return getVideoThumbnailStream(filePath);
         } else {
             BufferedInputStream imageStream = new BufferedInputStream(new FileInputStream(filePath), BUFFER_SIZE);
             return new ContentLengthInputStream(imageStream, (int) new File(filePath).length());
         }
     }
    
  10. 显而易见,crop方法有问题

    public String crop(String uri) {
            if (!belongsTo(uri)) {
                throw new IllegalArgumentException(String.format("URI [%1$s] doesn't have expected scheme [%2$s]", uri, scheme));
            }
            return uri.substring(uriPrefix.length());
        }
    
  11. uri.substring,有点意思,从uriPrefix的长度开始截取

    Scheme(String scheme) {
            this.scheme = scheme;
            uriPrefix = scheme + "://";
        }
    
  12. 这样就很明白了,UIL这个框架,直接从"file:// "往后,把具体的地址截取出来了,而且它直接用后面的地址获取到了InputStream,这样就可以避免7.0这个file://需要换成content://的问题,而避免了使用FileProvider。

  13. 最后附上一个没问题的例子。

    FileInputStream fileInputStream = new FileInputStream("/storage/emulated/0/Download/com.***.apk");
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,358评论 0 17
  • 只简述我发现问题的根源,有些是适配了7.0,会报权限失败问题,那是由于没有动态授权导致,接下来我一步一步给大家实现...
    Wocus阅读 2,347评论 4 5
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,498评论 25 707
  • Android7.0发布已经有一个多月了,Android7.0在给用户带来一些新的特性的同时,也给开发者带来了新的...
    东经315度阅读 1,340评论 0 14
  • 亲子日记第十天,今天上午蕙钰做的一起作业错了好几个,我看了看确实不简单,我从头到尾缕了一遍,自己分析透了开始...
    AA稳稳阅读 169评论 0 0