一个看似简单的Excel文件导入功能的实现和探究

854 阅读8分钟

前言

最近做的项目里有一个需求,就是在我们的app里可以选择打开微信或qq,然后从里面选取excel文件导入到我们的app,并且上传到服务器。 我细化了下这个需求,我们的app需要做到以下几点:

  • 用户可以通过按钮点击跳转到微信和qq,如果系统里未安装对应app给出提示。
  • 用户可以在微信或qq聊天窗口中,选择excel文件,用其他应用打开,然后选择用我们的app打开。
  • 我们的app获取这些导入的excel文件,并上传到服务器。
  • excel文件格式支持xls、xlsx、csv

既然知道了具体的需求,那我们就来看看怎么实现。

第一步

让我们的app可以打开微信或qq

实现代码如下:

/**
 * 打开微信
 */
private fun openWX() {
    try {
        val intent = Intent()
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        intent.component = ComponentName("com.tencent.mm", "com.tencent.mm.ui.LauncherUI")
        startActivity(intent)
    } catch (e: ActivityNotFoundException) {
        showToast(mActivity, getString(R.string.not_found_wechat))
    }
}

/**
 * 打开qq
 */
private fun openQQ() {
    try {
        val intent = Intent()
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        intent.component = ComponentName(
            "com.tencent.mobileqq",
            "com.tencent.mobileqq.activity.SplashActivity"
        )
        startActivity(intent)
    } catch (e: ActivityNotFoundException) {
        showToast(mActivity, getString(R.string.not_found_qq))
    }
}

有两点需要注意:

  • startActivity 代码需要加上 try-catch 捕获 ActivityNotFoundException 这个异常并处理。因为用户的手机里是有可能没有安装qq或者微信这两个app的,这个时候 startActivity 就会抛出这个异常。
  • intent 需要加上 Intent.FLAG_ACTIVITY_NEW_TASK 这个标记位,给qq或者微信开一个新的任务栈,这时在手机任务列表里,qq或者微信也会独立显示,不会跟我们的app显示到一起,这样更方便操作。

第二步

让我们的app可以打开Excel文件

怎么做呢?需要 在 AndroidManifest.xml 配置文件找到我们需要处理excel文件的 Activity 的标签,做出如下修改:

<activity
    android:name=".ui.activity.upload.GoodsImportActivity"
    android:screenOrientation="portrait"
    android:launchMode="singleTask">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:scheme="file" />
        <data android:scheme="content" />
        <data android:mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />
        <data android:mimeType="application/vnd.ms-excel" />
        <data android:mimeType="text/comma-separated-values" />
    </intent-filter>
</activity>

这样我们的app的这个activity就可以支持打开excel文件。在微信和qq里点击excel文件并选择用其他应用打开的话,我们的app就会出现在应用列表中。

需要注意的是,这个activity的启动模式需要设置为singleTask。试想一下,如果不设置为singleTask,那么可以通过微信或qq多次启动我们的这个activity,造成回退栈里存在多个此activity的实例,这显然是不允许的。所以我们希望这个activity实例在回退栈里只存在一个,并且启动这个activity的时候,如果这个activity实例之上还有其他activity实例,则需要其他activity实例出栈,让这个activity到栈顶。这里涉及到activity启动模式方面的知识,简单梳理一下: 启动模式分为四种,standard、singleTop、singleTask、singleInstance。

  • standard 标准模式。在AndroidManifest.xml文件里未定义launchMode 的话,默认使用这个。每次启动activity都会创建一个实例。
  • singleTop 栈顶复用模式。如果一个activity存在任务栈的栈顶,则再次启动时不重新创建实例,否则会重新创建实例加入到栈中。当它在栈顶并再次启动时,不会调用activity的onCreate和onStart方法,会调用onNewIntent方法。
  • singleTask 栈内复用模式。如果一个activity存在任务栈中,则再次启动这个activity时,则不会重新创建实例,并且如果这个activity之上还存在其他activity实例,则会让其他activity全部出栈,让其到栈顶。这时也不会调用onCreate和onStart方法会调用onNewIntent方法。
  • singleInstance 单实例模式。拥有singleTask的特性,并且在启动activity时,系统会单独创建一个任务栈,将这个activity加入进去,这个任务栈只能存在这一个activity实例,即使在这个activity再启动一个其他的activity也会安排到新的任务栈当中去。

这里就涉及到Intent的隐式启动的相关知识也梳理一下:

我们知道Intent是分为显式调用和隐式调用。如果一个Intent显示调用和隐式调用同时存在时,是以显示调用为主。显式调用是需要指定启动组件的包名和类名。而隐式调用是通过IntentFilter定义的过滤规则包括action、category、data的过滤规则来匹配寻找手机里可以响应这个intent的activity。所以这里我们的app需要在为这个activity定义一组intent-filter,让我们的activity可以响应这个隐式调用。

既然涉及到IntentFilter匹配,那就来梳理一下IntentFilter的匹配规则及注意事项:

  • IntentFilter过滤信息有action、category、data。一个过滤列表中的action、category、data可以存在多个。

  • Intent需要同时匹配aciton类别、category类别、data类别,才能启动这个activity。

  • action是一个字符串,action匹配就是Intent里的action和过滤规则中的action字符串的值相同。所以上面的例子里我们要定义一条action规则 <action android:name="android.intent.action.VIEW"/>,这样就能和qq或微信的intent中的action匹配上。

  • 一个<intent-filter>里可以有多个action,Intent里的只要有一个action能和<intent-filter>中的一个action匹配成功即可。

  • category匹配要求如果Intent里有category则 每一个category 都需要和<intent-filter>中的一个category相同。需要注意的是Intent中可以不设置category,但系统会在startActivity时默认加入android.intent.category.DEFAULT这个category。所以我们的app里的这个activity如果想要响应隐式调用Intent的话,则必须在<intent-filter>中加上<category android:name="android.intent.category.DEFAULT" />

  • data由mimeType 和 URI 组成。完整的URI结构是:<scheme>://<host>:<port>/[<path>|<pathPrefix>|<pathPattern]。URI如果不指定scheme和host,那么其他参数无效,整个URI也无效。mimeType表示媒体类型,结构是:type/subType。我们可以通过这个定义文件的格式。我们这里接收的是xls、xlsx和csv的excel文件,所以需要在里定义三个data,分别指定mimeType如下:

    <!--.xls文件-->

    <data android:mimeType="application/vnd.ms-excel" />

    <!--.xlsx文件-->

    <data android:mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />

    <!--.csv文件-->

    <data android:mimeType="text/comma-separated-values" />

    这里再列举一些其他常用的mimeType:

    mimeType类型 文件类型
    text/plain 纯文本
    text/html html文档
    image/png png图片
    video/* 视频
  • data匹配规则是如果含有data规则,则Intent必须含有一个data数据和里的一个data完全匹配。这里完全匹配的意思是 中出现的mimeType 和 URI分别至少有一条需要在Intent里包含。不太好理解,举一个例子:

    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:mimeType="text/plain" />
        <data android:mimeType="image/*" />
    </intent-filter>

这里要匹配这个<intent-filter>需要如下代码:

    val intent = Intent()
    intent.action = Intent.ACTION_VIEW
    intent.addCategory(Intent.CATEGORY_DEFAULT)
    intent.type = "text/plain" //或者intent.type = "image/*"
    startActivity(intent)

但如果把

<data android:mimeType="image/*" />

改为

<data android:mimeType="image/*" android:scheme="test" android:host="www.test.com" /> 那么上述Intent则无法匹配到这个data,因为第二条中包含URI信息,所以Intent里也得加入对应的URI信息才能匹配。如下把这行代码

intent.type = "text/plain"

改成

intent.setDataAndType(Uri.parse("test://www.test.com"),"text/plain") 即可匹配到。这里将第一条加入URI数据改为<data android:mimeType="text/plain" android:scheme="hehe" android:host="www.hehe.com"/>,上述这个Intent一样能够匹配成功。因为里的mimeType 和 URI 需要分开来看。

<data>写成如下这样

    <data android:mimeType="text/plain" android:scheme="hehe" android:host="www.hehe.com"/>
    <data android:mimeType="image/*" android:scheme="test" android:host="www.test.com"/>

    <data android:mimeType="text/plain" />
    <data android:mimeType="image/*" />
    <data android:scheme="hehe" android:host="www.hehe.com" />
    <data android:scheme="test" android:host="www.test.com" />

这两者的效果是一样的。

第三步

让我们这个activity找到微信或qq分享过来的文件

首先我们需要在activity里的onCreate()方法和onNewIntent这两个方法里通过Intent.getData()方法获取文件的URI,然后通过这个Uri.getPath获得文件的路径。 我们先来看一下获取的这个路径是什么样子的

QQ

微信
我们可以看到文件的路径是以FileProvider的形式向外提供的,这也是官方推荐的形式。 需要注意的是这样的路径是无法直接使用的,我们需要得到文件真实的路径。但是上网一查,目前没有办法获取这个真实路径。那么怎么得到这个文件呢?

有两个办法

  • 通过uri推断出具体的文件路径。例如上面这个路径应该是外置存储路径下的tencent文件夹下面的具体目录。qq的是/storage/emulated/0/Tencent/QQfile_recv/,微信的是/storage/emulated/0/tencent/MicroMsg/Download,可以截取uri的path得到真实路径。例如:
intent?.let {
    it.data?.let { uri ->
        val index = uri.path.toLowerCase().indexOf("tencent")
        val filePath = Environment.getExternalStorageDirectory().absolutePath + "/" + uri.path.substring(index)
        //do something else
        ...
    }
}

但是我并不推荐这么做,因为路径有可能发生改变,如果有一天不存放在tencent目录下,那这段代码就是错的。

  • 通过ContentResolver 获得文件的输入流,copy一份文件到我们自己的目录下,然后再对其做操作。
intent?.let {
    it.data?.let { uri ->
        val tempFilePath = Environment.getExternalStorageDirectory().absolutePath + "/myappdir/temp.xlsx"
        val tempFile = File(tempFilePath)
        val fileInputStream = contentResolver.openInputStream(uri)
        var outputStream: OutputStream? = null
        try {
            outputStream = FileOutputStream(tempFile)
            val buffer = ByteArray(1024)
            var len = -1
            while (run{len = fileInputStream.read(buffer);len != -1}) {
                outputStream.write(buffer,0,len)
            }
        } catch (e: java.lang.Exception){
            e.printStackTrace()
        } finally {
            try {
                outputStream?.close()
                fileInputStream.close()
            } catch (e: IOException){
                e.printStackTrace()
            }
        }
        //do something else
        ...
    }
}

推荐使用这种做法。

总结

至此我们已经完成了从微信或qq导入excel文件到己方app的需求。这个看似简单的需求,一步一步做下来,发现涉及的知识点还是挺多的。包括:

  • Intent隐式启动
  • IntentFilter匹配规则
  • activity启动模式
  • FileProvider、Uri

这些可以深挖的地方。以前我可能只是完成这项功能就好了,这次细细去探究才发现,这里面蕴含的知识点还真不少,最后有一种恍然大悟的感觉,看来以后要多多思考和总结。