当前位置: 首页 > news >正文

【Android入门】6、ContentProvider:跨程序的数据共享:访问其他 App、被其他 App 访问

八、跨程序数据共享 ContentProvider

应用场景: 例如通讯录/媒体库共享给其他APP, 拿到了其他APP的数据, 就可以提供更好的内容, 有更好的用户体验

  • Android曾用SharedPreferences的MODE_WORLD_READABLE和MODE_WORLD_WORLD_WRITABLE这两种操作模式, 供给其他APP访问当前APP的数据
  • Android目前更推荐用ContentProvider, 其更安全可靠, 是标准方式
  • ContentProvider是一种机制, 允许访问其他APP的数据, 且保证安全性(例如可只共享一部分数据)

8.1 运行时权限

AndroidManifest.xml中添加权限声明, 即可得到下图效果

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
 package="com.example.broadcasttest">
 <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
 ...
</manifest>

早期Android必须要求APP一次性申请所有权限(如位置/相机等), 若用户不同意则压根无法安装, 容易让厂商店大欺客
现在Android已经可以在运行时申请权限(如需要定位时才访问位置), 即使用户不同意也不影响使用

权限分为危险权限和普通权限, 前者共11组30个
在这里插入图片描述

在这里插入图片描述

8.2 打电话权限案例

效果如下

在这里插入图片描述

布局如下

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/makeCall"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Make Call" />
</LinearLayout>

权限文件如下

    <uses-permission android:name="android.permission.CALL_PHONE" />

代码如下

package com.example.contentprovidertest

import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        makeCall.setOnClickListener {
            if (ContextCompat.checkSelfPermission(
                    this,
                    Manifest.permission.CALL_PHONE
                ) != PackageManager.PERMISSION_GRANTED
            ) {
                // 未授权, 准备申请授权, 第二个参数是需要申请的权限范围, 第三个参数传一个唯一值即可
                ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CALL_PHONE), 1)
            } else {
                call() // 已授权, 直接拨号
            }
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        // Android系统弹窗, 请求用户的授权
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            1 -> {
                // 授权的结果封装在grantResults()方法中
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    call() // 用户授权通过, 直接拨号, 同时也会记录在Android系统中, 下次则不会弹窗请求用户的权限
                } else {
                    Toast.makeText(this, "You denied the permission", Toast.LENGTH_SHORT).show() // 用户授权未通过则弹窗消息
                }
            }
        }
    }

    private fun call() {
        try {
            val intent = Intent(Intent.ACTION_CALL)
            intent.data = Uri.parse("tel:10086")
            startActivity(intent)
        } catch (e: SecurityException) {
            e.printStackTrace()
        }
    }
} 

用户授权后, 也可在Settings->App->Permission Manager手动管理授权

8.3 ContentProvider 访问其他 App 的数据

若一个APP通过ContentProvider对外部提供了访问接口, 则其他APP都可访问此数据, 例如Android的通讯录, 短信, 媒体库都提供了类似接口
可通过getContentResolver()获取实例, 其提供了一系列增删改查方法
通过uri表示操作的是什么内容, 一般约定为如下示例, 即协议://app/表

content://com.example.app.provider/table1	// 查整个表
content://com.example.app.provider/table2/3	// 查表中id=3的行
// *表示匹配任意长度的任意字符
// #表示匹配任意长度的数字
content://com.example.app.provider/*		// 匹配任意表
content://com.example.app.provider/table1/#	// 匹配表的任意一行

拿到字符串uri后, 可通过下例访问

在这里插入图片描述

val uri = Uri.parse("content://com.example.app.provider/table1") // 得到uri对象
val cursor = contentResolver.query(uri, projection, selection, selectionArgs, sortOrder);
// query
while (cursor.moveToNext()) {
	val column1 = cursor.getString(cursor.getColumnIndex("column1"))
	val column2 = cursor.getInt(cursor.getColumnIndex("column2"))
}
cursor.close()
// insert
val values = contentValuesOf("column1" to "text", "column2" to 1)
contentResolver.insert(uri, values)
// update
val values = contentValuesOf("column1" to "")
contentResolver.update(uri, values, "column1 = ? and column2 = ?", arrayOf("text", "1"))
// delete
contentResolver.delete(uri, "column2 = ?", arrayOf("1"))
  • 下文以读取系统联系人案例演示

首先提前申请权限

    <uses-permission android:name="android.permission.READ_CONTACTS" />

设计布局

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <ListView
        android:id="@+id/contactsView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        />
</LinearLayout>

业务逻辑

class MainActivity : AppCompatActivity() {
    private val contactsList = ArrayList<String>()
    private lateinit var adapter: ArrayAdapter<String>
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, contactsList)
        contactsView.adapter = adapter
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS)
            != PackageManager.PERMISSION_GRANTED
        ) {
            ActivityCompat.requestPermissions(
                this,
                arrayOf(Manifest.permission.READ_CONTACTS), 1
            )
        } else {
            readContacts()
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            1 -> {
                if (grantResults.isNotEmpty()
                    && grantResults[0] == PackageManager.PERMISSION_GRANTED
                ) {
                    readContacts()
                } else {
                    Toast.makeText(
                        this, "You denied the permission",
                        Toast.LENGTH_SHORT
                    ).show()
                }
            }
        }
    }

    @SuppressLint("Range")
    private fun readContacts() {
        // 查询联系人数据
        contentResolver.query(
            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
            null, null, null, null
        )?.apply {
            while (moveToNext()) {
                // 获取联系人姓名
                val displayName = getString(
                    getColumnIndex(
                        ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME
                    )
                )
                // 获取联系人手机号
                val number = getString(
                    getColumnIndex(
                        ContactsContract.CommonDataKinds.Phone.NUMBER
                    )
                )
                contactsList.add("$displayName\n$number")
            }
            adapter.notifyDataSetChanged()
            close()
        }
    }
}

运行效果如下

在这里插入图片描述

8.4 创建 ContentProvider 供其他 APP 访问

需要新建一个类, 继承ContentProvider类, 需要override如下方法

package com.example.contentprovidertest

import android.content.ContentProvider
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri

class MyProvider : ContentProvider() {
    private val table1Dir = 0
    private val table1Item = 1
    private val table2Dir = 2
    private val table2Item = 3
    private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)

    init {
        uriMatcher.addURI("com.example.app.provider", "table1", table1Dir) // 访问table1表中的所有数据
        uriMatcher.addURI("com.example.app.provider ", "table1/#", table1Item) // 访问table1表中的单条数据
        uriMatcher.addURI("com.example.app.provider ", "table2", table2Dir) // 访问table2表中的所有数据
        uriMatcher.addURI("com.example.app.provider ", "table2/#", table2Item)  // 访问table2表中的单条数据
    }

    // 初始化ContentProvider的时候调用。通常会在这里完成对数据库的创建和
    // 升级等操作,返回true表示ContentProvider初始化成功,返回false则表示失败
    override fun onCreate(): Boolean {
        TODO("Not yet implemented")
    }

    override fun query(
        uri: Uri, projection: Array<String>?, selection: String?,
        selectionArgs: Array<String>?, sortOrder: String?
    ): Cursor? {
        when (uriMatcher.match(uri)) {
            table1Dir -> {
                // 查询table1表中的所有数据
            }
            table1Item -> {
                // 查询table1表中的单条数据
            }
            table2Dir -> {
                // 查询table2表中的所有数据
            }
            table2Item -> {
                // 查询table2表中的单条数据
            }
        }
    }

    // 根据传入的URI返回MIME类型
    override fun getType(uri: Uri) = when (uriMatcher.match(uri)) {
        table1Dir -> "vnd.android.cursor.dir/vnd.com.example.app.provider.table1"
        table1Item -> "vnd.android.cursor.item/vnd.com.example.app.provider.table1"
        table2Dir -> "vnd.android.cursor.dir/vnd.com.example.app.provider.table2"
        table2Item -> "vnd.android.cursor.item/vnd.com.example.app.provider.table2"
        else -> null
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        TODO("Not yet implemented")
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        TODO("Not yet implemented")
    }

    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        TODO("Not yet implemented")
    }
}
  • 其中 MIME字符串由3部分构成

    • 必须以vdn开头
    • 如果内容URI
      • 以路径结尾,则后接android.cursor.dir/
      • 以id结尾,则后接android.cursor.item/
    • 最后接上vnd.<authority>.<path>
  • 所以content://com.example.app.provider/table1的URI对应的MIME是vnd.android.cursor.dir/vnd.com.example.app.provider.table1

  • 所以content://com.example.app.provider/table1/1的URI对应的MIME是vnd.android.cursor.item/vnd.com.example.app.provider.table1
    因为访问者APP必须通过提供者APP暴露的指定URI访问, 所以没有安全问题

  • 案例如下, 首先在DatabaseTest提供一个ContentProvider
    New->Other->ContentProvider填写如下信息

在这里插入图片描述

上图操作后, AndroidStudio即会在AndroidManifest.xml中自动添加如下代码

<provider
    android:name=".DatabaseProvider"
    android:authorities="com.example.databasetest.provider"
    android:enabled="true"
    android:exported="true"></provider>

修改DatabaseProvider的代码如下

package com.example.databasetest

import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.content.UriMatcher
import android.os.CancellationSignal
import android.database.sqlite.SQLiteDatabase

class DatabaseProvider : ContentProvider() {

    private val bookDir = 0
    private val bookItem = 1
    private val categoryDir = 2
    private val categoryItem = 3
    private val authority = "com.example.databasetest.provider"
    private var dbHelper: MyDatabaseHelper? = null

    private val uriMatcher by lazy {
        val matcher = UriMatcher(UriMatcher.NO_MATCH)
        matcher.addURI(authority, "book", bookDir)
        matcher.addURI(authority, "book/#", bookItem)
        matcher.addURI(authority, "category", categoryDir)
        matcher.addURI(authority, "category/#", categoryItem)
        matcher
    }

    override fun onCreate() = context?.let {
        dbHelper = MyDatabaseHelper(it, "BookStore.db", 2)
        true
    } ?: false

    override fun query(
        uri: Uri,
        projection: Array<String>?,
        selection: String?,
        selectionArgs: Array<String>?,
        sortOrder: String?
    ) = dbHelper?.let {
        // 查询数据
        val db = it.readableDatabase
        val cursor = when (uriMatcher.match(uri)) {
            bookDir -> db.query("Book", projection, selection, selectionArgs, null, null, sortOrder)
            bookItem -> {
                val bookId = uri.pathSegments[1]
                db.query("Book", projection, "id = ?", arrayOf(bookId), null, null, sortOrder)
            }
            categoryDir -> db.query("Category", projection, selection, selectionArgs, null, null, sortOrder)
            categoryItem -> {
                val categoryId = uri.pathSegments[1]
                db.query("Category", projection, "id = ?", arrayOf(categoryId), null, null, sortOrder)
            }
            else -> null
        }
        cursor
    }

    override fun insert(uri: Uri, values: ContentValues?) = dbHelper?.let {
        // 添加数据
        val db = it.writableDatabase
        val uriReturn = when (uriMatcher.match(uri)) {
            bookDir, bookItem -> {
                val newBookId = db.insert("Book", null, values)
                Uri.parse("content://$authority/book/$newBookId")
            }
            categoryDir, categoryItem -> {
                val newCategoryId = db.insert("Category", null, values)
                Uri.parse("content://$authority/category/$newCategoryId")
            }
            else -> null
        }
        uriReturn
    }

    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?) =
        dbHelper?.let {
            // 更新数据
            val db = it.writableDatabase
            val updatedRows = when (uriMatcher.match(uri)) {
                bookDir -> db.update("Book", values, selection, selectionArgs)
                bookItem -> {
                    val bookId = uri.pathSegments[1]
                    db.update("Book", values, "id = ?", arrayOf(bookId))
                }
                categoryDir -> db.update("Category", values, selection, selectionArgs)
                categoryItem -> {
                    val categoryId = uri.pathSegments[1]
                    db.update("Category", values, "id = ?", arrayOf(categoryId))
                }
                else -> 0
            }
            updatedRows
        } ?: 0

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?) = dbHelper?.let {
        // 删除数据
        val db = it.writableDatabase
        val deletedRows = when (uriMatcher.match(uri)) {
            bookDir -> db.delete("Book", selection, selectionArgs)
            bookItem -> {
                val bookId = uri.pathSegments[1]
                db.delete("Book", "id = ?", arrayOf(bookId))
            }
            categoryDir -> db.delete("Category", selection, selectionArgs)
            categoryItem -> {
                val categoryId = uri.pathSegments[1]
                db.delete("Category", "id = ?", arrayOf(categoryId))
            }
            else -> 0
        }
        deletedRows
    } ?: 0

    override fun getType(uri: Uri) = when (uriMatcher.match(uri)) {
        bookDir -> "vnd.android.cursor.dir/vnd.com.example.databasetest.provider.book"
        bookItem -> "vnd.android.cursor.item/vnd.com.example.databasetest.provider.book"
        categoryDir -> "vnd.android.cursor.dir/vnd.com.example.databasetest.provider.category"
        categoryItem -> "vnd.android.cursor.item/vnd.com.example.databasetest.provider.category"
        else -> null
    }
}
  • 其次, 新建一个名为ProviderTest项目,其layout如下
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <Button
        android:id="@+id/addData"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Add To Book" />
    <Button
        android:id="@+id/queryData"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Query From Book" />
    <Button
        android:id="@+id/updateData"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Update Book" />
    <Button
        android:id="@+id/deleteData"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Delete From Book" />
</LinearLayout>

逻辑如下

package com.example.providertest

import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.contentValuesOf
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {
    var bookId: String? = null

    @SuppressLint("Range")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        addData.setOnClickListener {
            // 添加数据
            //首先调用了Uri.parse()方法将一个内容URI解析成Uri对象,然后把要添加的数据都存放到
            //ContentValues对象中,接着调用ContentResolver的insert()方法执行添加操作就可以
            //了。注意,insert()方法会返回一个Uri对象,这个对象中包含了新增数据的id,我们通过
            //getPathSegments()方法将这个id取出,稍后会用到它。
            val uri = Uri.parse("content://com.example.databasetest.provider/book")
            val values = contentValuesOf(
                "name" to "A Clash of Kings",
                "author" to "George Martin", "pages" to 1040, "price" to 22.85
            )
            val newUri = contentResolver.insert(uri, values)
            bookId = newUri?.pathSegments?.get(1)
        }
        queryData.setOnClickListener {
            // 查询数据
            //调用了Uri.parse()方法将一个内容URI解析成Uri对象,然后调用
            //ContentResolver的query()方法查询数据,查询的结果当然还是存放在Cursor对象中。之
            //后对Cursor进行遍历,从中取出查询结果,并一一打印出来
            val uri = Uri.parse("content://com.example.databasetest.provider/book")
            contentResolver.query(uri, null, null, null, null)?.apply {
                while (moveToNext()) {
                    val name = getString(getColumnIndex("name"))
                    val author = getString(getColumnIndex("author"))
                    val pages = getInt(getColumnIndex("pages"))
                    val price = getDouble(getColumnIndex("price"))
                    Log.d("MainActivity", "book name is $name")
                    Log.d("MainActivity", "book author is $author")
                    Log.d("MainActivity", "book pages is $pages")
                    Log.d("MainActivity", "book price is $price")
                }
                close()
            }
        }
        updateData.setOnClickListener {
            // 更新数据
            //为了不想让Book表中的其他行受到影响,在调用Uri.parse()方法时,
            //给内容URI的尾部增加了一个id,而这个id正是添加数据时所返回的。这就表示我们只希望更新
            //刚刚添加的那条数据,Book表中的其他行都不会受影响
            bookId?.let {
                val uri = Uri.parse(
                    "content://com.example.databasetest.provider/book/$it"
                )
                val values = contentValuesOf(
                    "name" to "A Storm of Swords",
                    "pages" to 1216, "price" to 24.05
                )
                contentResolver.update(uri, values, null, null)
            }
        }
        deleteData.setOnClickListener {
            // 删除数据
            //在内容URI里指定了一个id,因此只会删掉拥有相应id的那行数据,Book表中的其他数据都不会受影响
            bookId?.let {
                val uri = Uri.parse(
                    "content://com.example.databasetest.provider/book/$it"
                )
                contentResolver.delete(uri, null, null)
            }
        }
    }
}

在这里插入图片描述

点击添加书籍时, 会添加到DatabaseTest程序的数据库中
点击查询书籍时, 会打印如下日志

2022-08-17 14:12:52.362 7413-7413/com.example.providertest D/MainActivity: book name is Game of Thrones
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book author is George Martin
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book pages is 720
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book price is 20.85
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book name is The Da Vinci Code
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book author is Dan Brown
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book pages is 454
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book price is 16.96
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book name is The Lost Symbol
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book author is Dan Brown
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book pages is 510
2022-08-17 14:12:52.363 7413-7413/com.example.providertest D/MainActivity: book price is 19.95

点击更新书籍, 再点击查询书籍, 同样会打印在日志中

相关文章:

  • 文献学习(part102-A)--Autoencoders
  • SS-Model【6】:U2-Net
  • 创新战略|工业企业如何应对颠覆式变革带来的挑战?
  • HashMap不安全后果及ConcurrentHashMap线程安全原理
  • 22_access 阶段
  • 如何位图转换矢量图或者数字油画底稿
  • 阿里巴巴面试题- - -多线程并发篇(三十八)
  • python java php一起考研资料文件下载系统 微信小程序
  • 超好理解的子网和超网
  • Linux常用命令练习
  • Codeforces Round #813 (Div. 2) A. Wonderful Permutation
  • Windows Terminal远程连接阿里云
  • 了解MQ和安装使用RabbitMQ
  • Ubuntu 22.04 LTS 入门安装配置优化、开发软件安装一条龙
  • 软件工程综合实践课程第三周个人作业(工厂方法模式、抽象工厂模式的概念和实现及使用“反射技术+读取配置文件”的方法对工厂模式进行改进)
  • 【402天】跃迁之路——程序员高效学习方法论探索系列(实验阶段159-2018.03.14)...
  • Angular 响应式表单之下拉框
  • ES6核心特性
  • ES6系统学习----从Apollo Client看解构赋值
  • Java 9 被无情抛弃,Java 8 直接升级到 Java 10!!
  • laravel 用artisan创建自己的模板
  • Logstash 参考指南(目录)
  • MaxCompute访问TableStore(OTS) 数据
  • MQ框架的比较
  • 翻译:Hystrix - How To Use
  • 基于 Babel 的 npm 包最小化设置
  • 简单易用的leetcode开发测试工具(npm)
  • 解析 Webpack中import、require、按需加载的执行过程
  • 使用 @font-face
  • 责任链模式的两种实现
  • elasticsearch-head插件安装
  • raise 与 raise ... from 的区别
  • 东超科技获得千万级Pre-A轮融资,投资方为中科创星 ...
  • ​2020 年大前端技术趋势解读
  • ​Spring Boot 分片上传文件
  • #define 用法
  • #include到底该写在哪
  • #pragma data_seg 共享数据区(转)
  • $ git push -u origin master 推送到远程库出错
  • (C语言)输入自定义个数的整数,打印出最大值和最小值
  • (floyd+补集) poj 3275
  • (vue)el-checkbox 实现展示区分 label 和 value(展示值与选中获取值需不同)
  • (阿里巴巴 dubbo,有数据库,可执行 )dubbo zookeeper spring demo
  • (附源码)ssm教师工作量核算统计系统 毕业设计 162307
  • (十八)SpringBoot之发送QQ邮件
  • (轉貼) 寄發紅帖基本原則(教育部禮儀司頒布) (雜項)
  • **CI中自动类加载的用法总结
  • ./configure、make、make install 命令
  • .NET Core 实现 Redis 批量查询指定格式的Key
  • .Net Core与存储过程(一)
  • .NET Micro Framework 4.2 beta 源码探析
  • .net MySql
  • .NET Windows:删除文件夹后立即判断,有可能依然存在
  • .NET 指南:抽象化实现的基类
  • .NET版Word处理控件Aspose.words功能演示:在ASP.NET MVC中创建MS Word编辑器