当前位置:   article > 正文

8.4--创建自己的 ContentProvider_创建一个对外的contentprovider以供使用。

创建一个对外的contentprovider以供使用。

在上一节当中,我们学习了如何在自己的程序中访问其他应用程序的数据。总体来说思路还是非常简单的,只需要获取到该应用程序的内容URI,然后借助ContentResolver进行CRUD操作就可以了。可是你有没有想过,那些提供外部访问接口的应用程序都是如何实现这种功能的呢?它们又是怎样保证数据的安全性,使得隐私数据不会泄漏出去?学习完本节的知识后,你的疑惑将会被一一解开。

 

8.4.1 创建 ContentProvider 的步骤

如果要实现跨程序共享数据功能,可以通过新建一个类去继承ContentProvider 的方式来实现。ContentProvider 类中有6个抽象方法,我们在使用子类继承它的时候,需要将这6个抽象方法全部重写。

  1. class MyProvider : ContentProvider() {
  2. override fun insert(uri: Uri, values: ContentValues?): Uri? {
  3. TODO("Not yet implemented")
  4. }
  5. override fun query(
  6. uri: Uri,
  7. projection: Array<out String>?,
  8. selection: String?,
  9. selectionArgs: Array<out String>?,
  10. sortOrder: String?
  11. ): Cursor? {
  12. TODO("Not yet implemented")
  13. }
  14. override fun onCreate(): Boolean {
  15. TODO("Not yet implemented")
  16. }
  17. override fun update(
  18. uri: Uri,
  19. values: ContentValues?,
  20. selection: String?,
  21. selectionArgs: Array<out String>?
  22. ): Int {
  23. TODO("Not yet implemented")
  24. }
  25. override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
  26. TODO("Not yet implemented")
  27. }
  28. override fun getType(uri: Uri): String? {
  29. TODO("Not yet implemented")
  30. }
  31. }

对于这6个方法简单介绍一下把,已经见到很多次了:

(1)onCreate()。 初始化ContentProvider 的时候调用。通常会在这里完成对数据库的创建和升级等操作,返回true 表示初始化成功,返回false 则表示失败。

(2)query()。从ContentProvider 中查询数据。uri 参数用于确定查询哪张表,projection 参数用于确定查询哪些列,selection 和 selectionArgs 参数用于约束查询哪些行,sortOrder参数用于对结果进行排序,查询的结果放在Cursor 对象中返回。

(3)insert()。向内容提供器中添加一条数据。使用uri参数来确定要添加到的表,待添加的数据保存在values参数中。添加完成后,返回一个用于表示这条新记录的URI。

(4)update()。更新内容提供器中已有的数据。使用uri参数来确定更新哪一张表中的数据,新数据保存在values 参数中,selection和selectionArgs参数用于约束更新哪些行,受影响的行数将作为返回值返回。

(5)delete()。从内容提供器中删除数据。使用uri参数来确定删除哪一张表中的数据,selection和selectionArgs参数用于约束删除哪些行,被删除的行数将作为返回值返回。

(6)getType()。根据传入的内容URI来返回相应的MIME类型。

可以看到,几乎每一个方法都会带有Uri这个参数,这个参数也正是调用ContentResolver的增删改查方法时传递过来的。而现在,我们需要对传入的Uri参数进行解析,从中分析出调用方期望访问的表和数据。

回顾一下,一个标准的内容URI写法是这样的:

content://com.example.app.provider/table1这就表示调用方期望访问的是com.example.app这个应用的table1表中的数据。除此之外,我们还可以在这个内容URI的后面加上一个id,如下所示:

content://com.example.app.provider/table1/1

这就表示调用方期望访问的是com.example.app这个应用的tablel表中id为1的数据。

内容URI的格式主要就只有以上两种,以路径结尾就表示期望访问该表中所有的数据,以id 结尾就表示期望访问该表中拥有相应id的数据。我们可以使用通配符的方式来分别匹配这两种格式的内容URI,规则如下。

*:表示匹配任意长度的任意字符。

#:表示匹配任意长度的数字。

所以,一个能够匹配任意表的内容URI格式就可以写成:

content://com.example.app.provider/*

而一个能够匹配table1表中任意一行数据的内容URI格式就可以写成:

content://com.example.app.provider/table1/#

接着,我们再借助 UriMatcher 这个类就可以轻松地实现匹配内容URI的功能。UriMatcher中提供了一个adduRI()方法,这个方法接收3个参数,可以分别把authority、path和一个自定义代码传进去。这样,当调用UriMatcher的match()方法时,就可以将一个Uri对象传入,返回值是某个能够匹配这个Uri对象所对应的自定义代码,利用这个代码,我们就可以判断出调用方期望访问的是哪张表中的数据了。修改MyProvider中的代码,如下所示:

  1. class MyProvider : ContentProvider() {
  2. private val table1Dir = 0
  3. private val table1Item = 1
  4. private val table2Dir = 2
  5. private val table2Item = 3
  6. private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
  7. init {
  8. uriMatcher.addURI("com.example.app.provider","table1",table1Dir)
  9. uriMatcher.addURI("com.example.app.provider","table1/#",table1Item)
  10. uriMatcher.addURI("com.example.app.provider","table2",table2Dir)
  11. uriMatcher.addURI("com.example.app.provider","table2/#",table2Item)
  12. }
  13. override fun insert(uri: Uri, values: ContentValues?): Uri? {
  14. TODO("Not yet implemented")
  15. }
  16. override fun query(
  17. uri: Uri,
  18. projection: Array<out String>?,
  19. selection: String?,
  20. selectionArgs: Array<out String>?,
  21. sortOrder: String?
  22. ): Cursor? {
  23. when(uriMatcher.match(uri)){
  24. table1Dir -> {
  25. // 查询table1 表中的所有数据
  26. }
  27. table1Item -> {
  28. // 查询table1 表中的单条数据
  29. }
  30. table2Dir -> {
  31. // 查询table2 表中的所有数据
  32. }
  33. table2Item -> {
  34. // 查询table2 表中的单条数据
  35. }
  36. }
  37. return null
  38. }
  39. override fun onCreate(): Boolean {
  40. TODO("Not yet implemented")
  41. }
  42. override fun update(
  43. uri: Uri,
  44. values: ContentValues?,
  45. selection: String?,
  46. selectionArgs: Array<out String>?
  47. ): Int {
  48. TODO("Not yet implemented")
  49. }
  50. override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
  51. TODO("Not yet implemented")
  52. }
  53. override fun getType(uri: Uri): String? {
  54. TODO("Not yet implemented")
  55. }
  56. }

可以看到,MyProvider中新增了4个整型常量,其中TABLE1DIR表示访问tablel表中的所有数据,TABLE1ITEM表示访问table1表中的单条数据,TABLE2DIR表示访问table2表中的所有数据,TABLE2ITEM表示访问table2表中的单条数据。接着在静态代码块里我们创建了UriMatcher的实例,并调用addURI()方法,将期望匹配的内容URI格式传递进去,注意这里传入的路径参数是可以使用通配符的。然后当query()方法被调用的时候,就会通过UriMatcher的match()方法对传入的Uri对象进行匹配,如果发现UriMatcher中某个内容URI格式成功匹配了该Uri对象,则会返回相应的自定义代码,然后我们就可以判断出调用方期望访问的到底是什么数据了。

上述代码只是以query()方法为例做了个示范,其实insert()、update()、delete()这几个方法的实现也是差不多的,它们都会携带Uri这个参数,然后同样利用UriMatcher的match()方法判断出调用方期望访问的是哪张表,再对该表中的数据进行相应的操作就可以了。

除此之外,还有一个方法你会比较陌生,即getType()方法。它是所有的内容提供器都必须提供的一个方法,用于获取Uri对象所对应的MIME类型。一个内容URI所对应的MIME字符串主要由3部分组成,Android对这3个部分做了如下格式规定。

必须以vnd开头。

如果内容URI以路径结尾,则后接android.cursor.dir/,如果内容URI以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

现在我们可以继续完善MyProvider中的内容了,这次来实现getType()方法中的逻辑,代码如下所示:

  1. override fun getType(uri: Uri): String? = when(uriMatcher.match(uri)){
  2. table1Dir -> "vnd.android.cursor.dir/vnd.com.example.app.provider.table1"
  3. table1Item -> "vnd.android.cursor.item/vnd.com.example.app.provider.table1"
  4. table2Dir -> "vnd.android.cursor.dir/vnd.com.example.app.provider.table2"
  5. table2Item -> "vnd.android.cursor.item/vnd.com.example.app.provider.table2"
  6. else -> null
  7. }

到这里,一个完整的内容提供器就创建完成了,现在任何一个应用程序都可以使用ContentResolver来访问我们程序中的数据。那么前面所提到的,如何才能保证隐私数据不会泄漏出去呢?其实多亏了内容提供器的良好机制,这个问题在不知不觉中已经被解决了。因为所有的CRUD操作都一定要匹配到相应的内容URI格式才能进行的,而我们当然不可能向UriMatcher中添加隐私数据的URI,所以这部分数据根本无法被外部程序访问到,安全问题也就不存在了。
好了,创建内容提供器的步骤你也已经清楚了,下面就来实战一下,真正体验一回跨程序数据共享的功能。

 

8.4.2 实战跨程序数据共享

简单起见,我们还是在上一章中DatabaseTest项目的基础上继续开发,通过内容提供器来给它加入外部访问接口。打开DatabaseTest项目,首先将MyDatabaseHelper中使用Toast弹出创建数据库成功的提示去除掉,因为跨程序访问时我们不能直接使用Toast。然后创建一个内容提供器,右击com.example.broadcasttest包→New→Other→Content Provider。

可以看到,这里我们将内容提供器命名为DatabaseProvider,authority 指定为com.example.
databasetest.provider,Exported属性表示是否允许外部程序访问我们的内容提供器,Enabled属性表示是否启用这个内容提供器。将两个属性都勾中,点击Finish完成创建。

接着我们修改DatabaseProvider中的代码,如下所示:

  1. class DatabaseProvider : ContentProvider() {
  2. private val bookDir = 0
  3. private val bookItem = 1
  4. private val categoryDir = 2
  5. private val categoryItem = 3
  6. private val authority = "com.example.databasetest.provider"
  7. private var dbHelper:MyDatabaseHelper? = null
  8. // 懒加载uriMatcher 变量,只有第一次调用uriMatcher 变量才会启动这个代码块
  9. private val uriMatcher by lazy {
  10. val matcher = UriMatcher(UriMatcher.NO_MATCH)
  11. matcher.addURI(authority,"book",bookDir)
  12. matcher.addURI(authority,"book/#",bookItem)
  13. matcher.addURI(authority,"category",categoryDir)
  14. matcher.addURI(authority,"category/#",categoryItem)
  15. matcher
  16. }
  17. override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?) = dbHelper?.let {
  18. val db = it.readableDatabase
  19. val deletedRows = when(uriMatcher.match(uri)){
  20. bookDir -> db.delete("Book",selection,selectionArgs)
  21. bookItem -> {
  22. val bookId = uri.pathSegments[1]
  23. db.delete("Book","id = ?", arrayOf(bookId))
  24. }
  25. categoryDir -> db.delete("Category",selection,selectionArgs)
  26. categoryItem -> {
  27. val categoryId = uri.pathSegments[1]
  28. db.delete("Category","id = ?", arrayOf(categoryId))
  29. }
  30. else -> 0
  31. }
  32. deletedRows
  33. }?: 0
  34. override fun getType(uri: Uri) = when(uriMatcher.match(uri)){
  35. bookDir -> "vnd.android.corsor.dir/vnd.$authority.book"
  36. bookItem -> "vnd.android.corsor.item/vnd.$authority.book"
  37. categoryDir -> "vnd.android.corsor.dir/vnd.$authority.category"
  38. categoryItem -> "vnd.android.corsor.item/vnd.$authority.category"
  39. else -> null
  40. }
  41. override fun insert(uri: Uri, values: ContentValues?) = dbHelper?.let {
  42. // 添加数据
  43. val db = it.readableDatabase
  44. val uriReturn = when(uriMatcher.match(uri)){
  45. bookDir,bookItem -> {
  46. val newBookId = db.insert("Book",null,values)
  47. Uri.parse("content://$authority/book/$newBookId")
  48. }
  49. categoryDir,categoryItem -> {
  50. val newCategoryId = db.insert("Category",null,values)
  51. Uri.parse("content://$authority/category/$newCategoryId")
  52. }
  53. else -> null
  54. }
  55. uriReturn
  56. }
  57. // ?. == 非空逻辑 ?: == 空逻辑 2.7章有介绍哦。 还有返回值也可以省略 具体查看2.3.2函数 那节
  58. override fun onCreate() = context?.let {
  59. dbHelper = MyDatabaseHelper(it,"BookStore",2)
  60. true
  61. }?:false
  62. override fun query(
  63. uri: Uri, projection: Array<String>?, selection: String?,
  64. selectionArgs: Array<String>?, sortOrder: String?
  65. ) = dbHelper?.let {
  66. // 查询数据
  67. val db = it.readableDatabase
  68. val cursor = when(uriMatcher.match(uri)){
  69. bookDir -> {
  70. db.query("Book",projection,selection,selectionArgs,null,null,sortOrder)
  71. }
  72. bookItem -> {
  73. val bookId = uri.pathSegments[1]
  74. db.query("Book",projection,"id = ?", arrayOf(bookId),null,null,sortOrder)
  75. }
  76. categoryDir -> {
  77. db.query("Category",projection,selection,selectionArgs,null,null,sortOrder)
  78. }
  79. categoryItem -> {
  80. val categoryId = uri.pathSegments[1]
  81. db.query("Category",projection,"id = ?", arrayOf(categoryId),null,null,sortOrder)
  82. }
  83. else -> null
  84. }
  85. cursor
  86. }
  87. override fun update(
  88. uri: Uri, values: ContentValues?, selection: String?,
  89. selectionArgs: Array<String>?
  90. ) = dbHelper?.let {
  91. val db = it.readableDatabase
  92. val updateRows = when(uriMatcher.match(uri)){
  93. bookDir -> db.update("Book",values,selection,selectionArgs)
  94. bookItem -> {
  95. val bookId = uri.pathSegments[1]
  96. db.update("Book",values, "id = ?", arrayOf(bookId))
  97. }
  98. categoryDir -> db.update("Category",values,selection,selectionArgs)
  99. categoryItem -> {
  100. val categoryId = uri.pathSegments[1]
  101. db.update("Category",values,"id = ?", arrayOf(categoryId))
  102. }
  103. else -> 0
  104. }
  105. updateRows
  106. }?:0
  107. }

代码虽然很长,不过不用担心,这些内容不难理解,因为使用的全都是上一小节中我们学到的知识。首先在类的一开始,同样是定义了4个变量,分别用于表示访问Book 表中的所有数据,访问Book 表中的单条数据,访问Category 表中的所有数据和访问Category 表中的单条数据。最后在一个by lazy 代码块里对UriMatcher(Uri匹配器) 进行了初始化操作,将期望匹配的几种URI 格式添加了进去。by lazy 代码块是Kotlin 提供的一种懒加载技术,代码块中的代码开始并不会执行,只有当uriMatcher 变量首次被调用的时候才会执行。并且将代码块中最后一行代码的返回值 赋给 UriMatcher。

接下来就是每个抽象方法的具体实现了,先来看一下onCreate() 方法。这个方法的代码很短,但是语法可能有些特殊。这里我们综合利用了Getter 方法语法糖 、?. 非空判断操作符、?: 空判断操作符、let 函数以及单行代码函数语法糖。首先调用了getContext() 方法并借助 ?. 操作符和let 函数判断它的返回值是否为空:如果为空就使用?:  操作符返回false,表示ContentProvider 初始化失败;如果不为空就执行let 函数中的代码。在let 函数中创建了一个MyDatabaseHelper 的实例,然后返回true 表示ContentProvider 初始化成功。由于我们借助了多个操作符和标准函数,因此这段逻辑是在一行表达式内完成的,符合单行代码函数的语法糖要求,所以直接使用等号连接返回值即可。其他几个方法的语法结果是类似的,相信各位看官能看得懂。

接着看一下query() 方法,在这个方法中首先获取了SQLiteDatabase 的实例,然后根据传入的Uri 参数判断用户想要访问哪张表,再调用SQLiteDatabase 的query() 进行查询,并将Cursor对象返回就可以了。注意,当访问单条数据的时候,调用了Uri 对象的getPathSegments() 方法,它会将内容URI 权限之后的部分以“/” 符号进行分割(也就是authority 后面的path开始),并把分割的结果放入一个字符串列表中,那这个列表的第0个位置存放的就是路径(table),第1个位置存放的就是id了。得到了id 之后,再通过selection 和 selectionArgs 参数进行约束,就实现了查询单条数据的功能。

再往后就是insert()方法,同样它也是先获取到了SQLiteDatabase的实例,然后根据传入的Uri参数判断出用户想要往哪张表里添加数据,再调用SQLiteDatabase的insert()方法进行添加就可以了。注意insert()方法要求返回一个能够表示这条新增数据的URI,所以我们还需要调用Uri.parse()方法来将一个内容URI解析成Uri对象,当然这个内容URI是以新增数据的id结尾的。

接下来就是update()方法了,相信这个方法中的代码已经完全难不倒你了。也是先获取SQLiteDatabase的实例,然后根据传入的Uri参数判断出用户想要更新哪张表里的数据,再调用SQLiteDatabase的update()方法进行更新就好了,受影响的行数将作为返回值返回。

下面是delete()方法,是不是感觉越到后面越轻松了?因为你已经渐入佳境,真正地找到窍门了。这里仍然是先获取到SQLiteDatabase的实例,然后根据传入的Uri参数判断出用户想要删除哪张表里的数据,再调用SQLiteDatabase的delete()方法进行删除就好了,被删除的行数将作为返回值返回。

最后是getType()方法,这个方法中的代码完全是按照上一节中介绍的格式规则编写的,相信已经没有什么解释的必要了。这样我们就将内容提供器中的代码全部编写完了。
 

另外还有一点需要注意,内容提供器一定要在AndroidManifest.xml文件中注册才可以使用。不过幸运的是,由于我们是使用Android Studio的快捷方式创建的内容提供器,因此注册这一步已经被自动完成了。打开AndroidManifest.xml文件瞧一瞧,代码如下所示:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <manifest xmlns:android="http://schemas.android.com/apk/res/android"
  3. package="com.example.databasetest">
  4. <application
  5. android:allowBackup="true"
  6. android:icon="@mipmap/ic_launcher"
  7. android:label="@string/app_name"
  8. android:roundIcon="@mipmap/ic_launcher_round"
  9. android:supportsRtl="true"
  10. android:theme="@style/AppTheme">
  11. <provider
  12. android:name=".DatabaseProvider"
  13. android:authorities="com.example.databasetest.provider"
  14. android:enabled="true"
  15. android:exported="true"></provider>
  16. <activity android:name=".MainActivity">
  17. <intent-filter>
  18. <action android:name="android.intent.action.MAIN" />
  19. <category android:name="android.intent.category.LAUNCHER" />
  20. </intent-filter>
  21. </activity>
  22. </application>
  23. </manifest>

 

可以看到,<application>标签内出现了一个新的标签<provider>,我们使用它来对DatabaseProvider这个内容提供器进行注册。android:name 属性指定了DatabaseProvider的类名,android:authorities 属性指定了DatabaseProvider的authority,而enabled和exported属性则是根据我们刚才勾选的状态自动生成的,这里表示允许DatabaseProvider被其他应用程序进行访问。

现在DatabaseTest这个项目就已经拥有了跨程序共享数据的功能了,我们赶快来尝试一下。
首先需要将DatabaseTest程序从模拟器中删除掉,以防止上一章中产生的遗留数据对我们造成干扰。然后运行一下项目,将DatabaseTest程序重新安装在模拟器上了。接着关闭掉 DatabaseTest这个项目,并创建一个新项目ProviderTest,我们就将通过这个程序去访问DatabaseTest中的数据。

布局文件很简单,里面放置了4个按钮,分别用于添加、查询、修改和删除数据。然后修改MainActivity中的代码,如下所示:

  1. class MainActivity : AppCompatActivity() {
  2. private var bookId:String? = null
  3. override fun onCreate(savedInstanceState: Bundle?) {
  4. super.onCreate(savedInstanceState)
  5. setContentView(R.layout.activity_main)
  6. addData.setOnClickListener {
  7. // 添加数据
  8. val uri = Uri.parse("content://com.example.databasetest.provider/book")
  9. val values = contentValuesOf(
  10. "name" to "A Clash of Kings",
  11. "author" to "George Martin",
  12. "pages" to 1040,
  13. "price" to 22.85
  14. )
  15. val newUri = contentResolver.insert(uri, values)
  16. bookId = newUri?.pathSegments?.get(1)
  17. }
  18. queryData.setOnClickListener {
  19. // 查询数据
  20. val uri = Uri.parse("content://com.example.databasetest.provider/book")
  21. contentResolver.query(uri, null, null, null, null)?.apply {
  22. while (moveToNext()){
  23. val name = getString(getColumnIndex("name"))
  24. val author = getString(getColumnIndex("author"))
  25. val pages = getInt(getColumnIndex("pages"))
  26. val price = getDouble(getColumnIndex("price"))
  27. Log.d("MainActivity","book name is $name")
  28. Log.d("MainActivity","book name is $author")
  29. Log.d("MainActivity","book name is $pages")
  30. Log.d("MainActivity","book name is $price")
  31. }
  32. close()
  33. }
  34. }
  35. updateData.setOnClickListener {
  36. // 更新数据 更新我们刚刚添加进去的数据
  37. bookId?.let {
  38. val uri = Uri.parse("content://com.example.databasetest.provider/book/$it")
  39. val values = contentValuesOf(
  40. "name" to "A Store of Swords",
  41. "pages" to 1216,
  42. "price" to 24.05
  43. )
  44. contentResolver.update(uri,values,null,null)
  45. }
  46. }
  47. deleteData.setOnClickListener {
  48. // 删除数据 删除我们刚刚添加进去的数据
  49. bookId?.let {
  50. val uri = Uri.parse("content://com.example.databasetest.provider/book/$it")
  51. contentResolver.delete(uri,null,null)
  52. }
  53. }
  54. }
  55. }

可以看到,我们分别在这4个按钮的点击事件里面处理了增删改查的逻辑。添加数据的时候,首先调用了Uri.parse()方法将一个内容URI解析成Uri对象,然后把要添加的数据都存放到ContentValues 对象中,接着调用ContentResolver的insert()方法执行添加操作就可以了。注意insert()方法会返回一个Uri对象,这个对象中包含了新增数据的id,我们通过getPathSegments()方法将这个id取出,稍后会用到它。

查询数据的时候,同样是调用了Uri.parse()方法将一个内容URI解析成Uri对象,然后调用ContentResolver的query()方法去查询数据,查询的结果当然还是存放在Cursor对象中的。之后对Cursor进行遍历,从中取出查询结果,并一一打印出来。

更新数据的时候,也是先将内容URI解析成Uri对象,然后把想要更新的数据存放到ContentValues 对象中,再调用ContentResolver的update()方法执行更新操作就可以了。注意这里我们为了不想让Book表中的其他行受到影响,在调用Uri.parse()方法时,给内容URI的尾部增加了一个id,而这个id正是添加数据时所返回的。这就表示我们只希望更新刚刚添加的那条数据,Book表中的其他行都不会受影响。

删除数据的时候,也是使用同样的方法解析了一个以id结尾的内容URI,然后调用ContentResolver的delete()方法执行删除操作就可以了。由于我们在内容URI里指定了一个id,因此只会删掉拥有相应id的那行数据,Book表中的其他数据都不会受影响。

现在运行一下ProviderTest项目 从上到下依次点击按钮来试验结果吧。

呼呼,这一章终于写完了,好累,即使是站在巨人的肩膀上学习也觉得累,更何况巨人是怎么学习的呢?各位看官们,爱拼才会赢,加油吧大家。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/在线问答5/article/detail/941057
推荐阅读
相关标签
  

闽ICP备14008679号