05d42cad9f4c71c762e7f783218b60f8.gif

91

次推文

LZ-Says

每个生命体的存在,其实本质都是一个复杂的过程。很多时候,无需追求完美的理想情况,毕竟,You are just you。

9f7f4fc12f591cbb9164d16e880c7081.gif

免责声明

为了避免收费的小哥哥干我,或者出现其它不好的情况,这里特意注明下:

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

前言

前段时间,公司突然来一需求:

  • 调研某款 App Android 版微信分享来源动态原理以及实现方式

第一时间,当然是看看网上有没有前辈开源,借鉴(CV 大法)一波。

查询结果真的是悲喜交加:

  • 开森的是,有人研究过这个东西,也封装好了对应的 SDK。

  • 悲剧的是收费,目前已了解的情况最低 100。

对于本身在帝都讨生活的落魄小 Android 而言,无疑是一笔巨款 (手动滑稽~勿喷~)。

都说穷人家的孩子早当家,不得已开始了逆向、分析之路 ???

相关代码已上传 GitHub,当然为了不给自己找事儿,本地命中库就不提供了,自己逆向去拿吧,地址如下:

  • https://github.com/HLQ-Struggle/share_wechat

效果图

空谈无用,来个实际效果图最棒,这里就以我梦想殿堂 App 为例进行测试咯。 

8a93b588ca6f0e81dceeeb79851d326c.gif

准备工具

基于个人了解简单概述:

  • ApkTools:一般就是为了改包、回包,捎带脚拿个资源文件。

  • ClassyShark:一款贼方便分析 Apk 工具,一般用于看看大厂都玩啥。

  • dex2jar:将 .dex 文件转换为 .class 文件。

  • JD-GUI:主要是查看反编译后的源代码。

下面附上相关工具网盘链接:

  • 链接:pan.baidu.com/s/1Ll5cTqMu…  密码:20fl

实战开搞

在正式开始前,先来见识下 ClassyShark 这个神器吧。

一、Hi,ClassyShark

首先进入你下载好的 ClassyShark.jar 目录中,随后执行如下命令即可:

java -jar ClassyShark.jar

示意图如下:

ca11e251c1a28b08e4028c12835f141e.png

随后在打开的可视化工具中将想看的 Apk 直接拖进去即可:

8edb768f71500fd82e476748786b884b.png

拖进去之后点击包名,会有一个对当前 Apk 的简单概述:

1735647b980b35af37697ad95ae72bb2.png

点击 Methods count 可以查看当前 Apk 方法数:

128a8ac0d09588423d90fdf9a5ca71cc.png

当然你可以继续往下一层级查看,比如我点击 bilibili:

922e56fe07c8dfcdd7b6ae6c33c365d6.png

同样也可以导出文件,这里不作为本文重点阐述了,有兴趣的可以自己研究~

二、逆向分析走起

首先,网上下载目标 App,并将后缀名修改为 zip,随后解压进入该目录:

50522bed4da44aba74a22f76b3b1d07d.png

手动进入已下载完成的 dex-tools-2.1-SNAPSHOT 目录中,执行如下命令:

sh d2j-dex2jar.sh [目标 dex 文件地址]

例如:

833b5bb7d9f323a4e875c47cdf66b766.png

完成之后,将会在 dex-tools-2.1-SNAPSHOT 目录中生成 classes-dex2jar.jar 文件,这里文件就是我们接下来逆向分析的靠山呐。

随后将生成的 jar 文件拖入 JD-GUI 中。

ae5ac03dca32c6bed4c12b177b44e06b.png

查看 AndroidManifest 获取到当前应用包名,有助于我们一步到位~

由于目标 App 是在文章的详情页中提供分享微信消息回话以及朋友圈,详情一般个人命名为 XxxDetailsActivity,根据这个思路去搜索。

65ebc59eea81b147ec961ee1efe9ac1b.png

有些尴尬啊,怎么搜索到了腾讯的 SDK 呢?

还是手动人工查找吧,???

2680e3d4ca9cc857ea3644ccdf2a1123.png

在这块发现个比较有意思的东西,可能是我比较 low 吧。一般而言,我们都知道混淆实体类是肯定不能被混淆的,不然就会出现找不到的情况。那么奇怪了,昨天逆向 B 站 Apk,我竟然没发现实体类,难道他们的实体类有其他神操作?还是说分包太多我没找到?

终于找到你,文章详情页!!!

58d1fa25b2856885fc898383e26cc7bc.png

操作 App,发现是点击按钮弹出底部分享对话框,原版如下:

befa8f798a1a85e62a8b8a137589d23f.png

随后继续在代码中查看,果然:

fed602b8047527b361595df36fd14f45.png

这个就很好理解了,自定义一个底部对话框,点击传递分享的 Url 以及分享类型。现在我们去 ShareArticleDialog 这个类中验证一下猜想是否正确?

03799e8b5845bff5fa29cd53a5ee02fe.png

看,0 应该是代表分享微信消息会话,1 代表分享朋友圈。

经过一番排查,发现最终是通过调用如下方法进行分享微信:

public static int send(Context paramContext, String paramString1, String paramString2, String paramString3, Bundle paramBundle) {    CURRENT_SHARE_CLIENT = null;    if (paramContext == null || paramString1 == null || paramString1.length() == 0 || paramString2 == null || paramString2.length() == 0) {      Log.w("MMessageAct", "send fail, invalid arguments");      return -1;    }     Intent intent = new Intent();    intent.setClassName(paramString1, paramString2);    if (paramBundle != null)      intent.putExtras(paramBundle);     intent.putExtra("_mmessage_sdkVersion", 603979778);    int i = getPackageSign(paramContext);    if (i == -1)      return -1;     CURRENT_SHARE_CLIENT = shareClient.get(i);    intent.putExtra("_mmessage_appPackage", "这里换成要借壳 App 包名");    StringBuilder stringBuilder = new StringBuilder();    stringBuilder.append("weixin://sendreq?appid=");    stringBuilder.append("这里换成要借壳 AppId");    intent.putExtra("_mmessage_content", stringBuilder.toString());    intent.putExtra("_mmessage_checksum", MMessageUtil.signatures(paramString3, paramContext.getPackageName()));    intent.addFlags(268435456).addFlags(134217728);    try {      paramContext.startActivity(intent);      StringBuilder stringBuilder1 = new StringBuilder();      this();      stringBuilder1.append("send mm message, intent=");      stringBuilder1.append(intent);      Log.d("MMessageAct", stringBuilder1.toString());      return i;    } catch (Exception exception) {      exception.printStackTrace();      Log.d("MMessageAct", "send fail, target ActivityNotFound");      return -1;    } }

在查看微信 SDK 中也发现类似代码,由于掘金这个上传图片宽高我现在还不会调整,暂时防止目录位置,感兴趣的小伙伴自行查看:

1beed1f155ceba123779df1d057922b6.png

其它细节就不一一分析了,直接上代码咯~

三、附上代码~

其实本质借壳分享,个人的理解如下:

  • 第一步:绕过微信检测,例如包名、签名是否和微信开放平台绑定一致;

  • 第二部:组装参数,直接直击深处,分享微信。

由于此次是 Flutter 项目,不得不的面对的是与原生 Android 的交互。由于我是刚刚入坑 Flutter 几周,内心真的是忐忑不安。

不过值得让人赞叹的是,Flutter 的生态,真的贼棒!尤其我鸡老大,神一般存在!默默的感谢我大哥~!

0. 简单聊下 Flutter 与交互

在 Flutter 中文社区中官网对此有这样的一段描述:

Flutter 使用了灵活的系统,它允许你调用相关平台的 API,无论是 Android 中的 Java 或 Kotlin 代码,还是 iOS 中的 Objective-C 或 Swift 代码。

Flutter 内置的平台特定 API 支持不依赖于任何生成代码,而是灵活的依赖于传递消息格式。或者,你也可以使用 Pigeon 这个

package,通过生成代码来发送结构化类型安全消息。

Google

应用程序中的 Flutter 部分通过平台通道向其宿主(应用程序中的 iOS 或 Android 部分)发送消息。

宿主监听平台通道并接收消息。然后,它使用原生编程语言来调用任意数量的相关平台 API,并将响应发送回客户端(即应用程序中的 Flutter 部分)。

Google

也就是说,Flutter 充分给予我们调用原生 Api 的权利,关键桥梁便是这个通道消息。

下面一起来看下官方的图:

bea0a44153e9faedef343b4bfea6f244.png

消息和响应以异步的形式进行传递,以确保用户界面能够保持响应。

客户端做方法调用的时候 MethodChannel 会负责响应,从平台一侧来讲,Android 系统上使用 MethodChannelAndroid、 iOS 系统使用 MethodChanneliOS 来接收和返回来自 MethodChannel 的方法调用。

Google

1. 引入三方库

api 'com.tencent.mm.opensdk:wechat-sdk-android-without-mta:+'// 主要用于将分享的在线图片转换为 Bitmapimplementation 'com.github.bumptech.glide:glide:4.11.0'annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'implementation 'com.google.code.gson:gson:2.8.6'

2. 完善混淆文件

# 保护我方输出(保护实体类不被混淆)-keep public class com.Your Package Name.bean.**{*;}# Gson-keepattributes Signature# Gson specific classes-keep class sun.misc.Unsafe { *; }-keep class com.google.gson.** { *; }# Application classes that will be serialized/deserialized over Gson-keep class com.google.gson.examples.android.model.** { *; }

3. 编写原生 Android 工具类

这里具体还是需要结合实际项目需求而定,不过通用型的一些东西必须要有:

动态检测宿主,也可以理解为动态检测借壳目标是否存在;

而剩下的则是分享微信了,这里简单放置关键代码,详情可点击文章开始的 GitHub 地址。 

package com.hlq.struggle.utilsimport android.content.Contextimport android.content.Intentimport android.graphics.Bitmapimport android.os.Bundleimport com.bumptech.glide.Glideimport com.bumptech.glide.load.DataSourceimport com.bumptech.glide.load.engine.GlideExceptionimport com.bumptech.glide.request.RequestListenerimport com.bumptech.glide.request.target.Targetimport com.google.gson.Gsonimport com.google.gson.reflect.TypeTokenimport com.hlq.struggle.app.appInfoJsonimport com.hlq.struggle.bean.AppInfoBeanimport com.tencent.mm.opensdk.modelmsg.SendMessageToWXimport com.tencent.mm.opensdk.modelmsg.WXMediaMessageimport com.tencent.mm.opensdk.modelmsg.WXMediaMessage.IMediaObjectimport com.tencent.mm.opensdk.modelmsg.WXWebpageObjectimport java.io.ByteArrayOutputStreamimport java.io.IOException/** * @author:HLQ_Struggle * @date:2020/6/27 * @desc: */@Suppress("SpellCheckingInspection")class ShareWeChatUtils {    companion object {        /**         * 解析本地缓存 App 信息         */        private fun getLocalAppCache(): ArrayList {            return Gson().fromJson(                    appInfoJson,                    object : TypeToken>() {}.type            )        }        /**         * 检测用户设备安装 App 信息         */        fun checkAppInstalled(context: Context): Int {            var tempCount = -1            // 获取本地宿主 App 信息            val appInfoList = getLocalAppCache()            // 获取用户设备已安装 App 信息            val packageManager = context.packageManager            val installPackageList = packageManager.getInstalledPackages(0)            if (installPackageList.isEmpty()) {                return 0            }            for (packageInfo in installPackageList) {                for (appInfo in appInfoList) {                    if (packageInfo.packageName == appInfo.packageName) {                        tempCount++                    }                }            }            return tempCount        }        /**         * 命中已安装 App         */        private fun hitInstalledApp(context: Context): AppInfoBean? {            // 获取本地宿主 App 信息            val appInfoList = getLocalAppCache()            // 获取用户设备已安装 App 信息            val packageManager = context.packageManager            // 能进入方法说明本地已存在命中 App,使用时还需要预防            val installPackageList = packageManager.getInstalledPackages(0)            for (packageInfo in installPackageList) {                for (appInfo in appInfoList) {                    if (packageInfo.packageName == appInfo.packageName) {                        return appInfo                    }                }            }            return null        }        /**         * 分享微信         */        fun shareWeChat(                context: Context,                shareType: Int,                url: String,                title: String,                text: String,                paramString4: String?,                umId: String?        ) {            Glide.with(context).asBitmap().load(paramString4)                    .listener(object : RequestListener {                        override fun onLoadFailed(                                param1GlideException: GlideException?,                                param1Object: Any,                                param1Target: Target,                                param1Boolean: Boolean                        ): Boolean {                            LogUtils.logE(" ---> Load Image Failed")                            return false                        }                        override fun onResourceReady(                                param1Bitmap: Bitmap?,                                param1Object: Any,                                param1Target: Target,                                param1DataSource: DataSource,                                param1Boolean: Boolean                        ): Boolean {                            LogUtils.logE(" ---> Load Image Ready")                            val i =                                    send(                                            context,                                            shareType,                                            url,                                            title,                                            text,                                            param1Bitmap                                    )                            val stringBuilder = StringBuilder()                            stringBuilder.append("send index: ")                            stringBuilder.append(i)                            LogUtils.logE(" ---> Ready stringBuilder.toString() :$stringBuilder")                            return false                        }                    }).preload(200, 200)        }        private fun send(                paramContext: Context,                paramInt: Int,                paramString1: String,                paramString2: String,                paramString3: String,                paramBitmap: Bitmap?        ): Int {            val stringBuilder = StringBuilder()            stringBuilder.append("share url: ")            stringBuilder.append(paramString1)            LogUtils.logE(" ---> send :$stringBuilder")            val wXWebpageObject = WXWebpageObject()            wXWebpageObject.webpageUrl = paramString1            val wXMediaMessage = WXMediaMessage(wXWebpageObject as IMediaObject)            wXMediaMessage.title = paramString2            wXMediaMessage.description = paramString3            wXMediaMessage.thumbData =                    bmpToByteArray(                            paramContext,                            Bitmap.createScaledBitmap(paramBitmap!!, 150, 150, true),                            true                    )            val req = SendMessageToWX.Req()            req.transaction =                    buildTransaction(                            "webpage"                    )            req.message = wXMediaMessage            req.scene = paramInt            val bundle = Bundle()            req.toBundle(bundle)            return sendToWx(                    paramContext,                    "weixin://sendreq?appid=wxd930ea5d5a258f4f",                    bundle            )        }        private fun buildTransaction(paramString: String): String {            var paramString: String? = paramString            paramString = if (paramString == null) {                System.currentTimeMillis().toString()            } else {                val stringBuilder = StringBuilder()                stringBuilder.append(paramString)                stringBuilder.append(System.currentTimeMillis())                stringBuilder.toString()            }            return paramString        }        private fun bmpToByteArray(                paramContext: Context?,                paramBitmap: Bitmap,                paramBoolean: Boolean        ): ByteArray? {            val byteArrayOutputStream =                    ByteArrayOutputStream()            try {                paramBitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)                if (paramBoolean) paramBitmap.recycle()                val arrayOfByte = byteArrayOutputStream.toByteArray()                byteArrayOutputStream.close()                return arrayOfByte            } catch (iOException: IOException) {                iOException.printStackTrace()            }            return null        }        private fun sendToWx(                paramContext: Context?,                paramString: String?,                paramBundle: Bundle?        ): Int {            return send(                    paramContext,                    "com.tencent.mm",                    "com.tencent.mm.plugin.base.stub.WXEntryActivity",                    paramString,                    paramBundle            )        }        private fun send(                paramContext: Context?,                packageName: String?,                className: String?,                paramString3: String?,                paramBundle: Bundle?        ): Int {            if (paramContext == null || packageName == null || packageName.isEmpty() || className == null || className.isEmpty()) {                LogUtils.logE(" ---> send fail, invalid arguments")                return -1            }            val appInfoBean = hitInstalledApp(paramContext)            val intent = Intent()            intent.setClassName(packageName, className)            if (paramBundle != null) intent.putExtras(paramBundle)            intent.putExtra("_mmessage_sdkVersion", 603979778)            intent.putExtra("_mmessage_appPackage", appInfoBean?.packageName)            val stringBuilder = StringBuilder()            stringBuilder.append("weixin://sendreq?appid=")            stringBuilder.append(appInfoBean?.packageSign)            intent.putExtra("_mmessage_content", stringBuilder.toString())            intent.putExtra(                    "_mmessage_checksum",                    MMessageUtils.signatures(paramString3, paramContext.packageName)            )            intent.addFlags(268435456).addFlags(134217728)            return try {                paramContext.startActivity(intent)                val sb = StringBuilder()                sb.append("send mm message, intent=")                sb.append(intent)                LogUtils.logE(" ---> sb :$sb")                0            } catch (exception: Exception) {                exception.printStackTrace()                LogUtils.logE(" --->  send fail, target ActivityNotFound")                -1            }        }    }}

4. 对 Flutter 暴露通道

这块需要注意几点,现在你可以理解为你在编写一个 Flutter 的小型插件,那么你需要向外部暴露一些你规定的类型,或者说方法。这个不难理解吧。

好比你去调用某个 SDK,官方一定是告知了一些重要的特性。那么针对我们现在的这个小插件,它比较关键的特性又是什么?

关于这个特性,个人这里分为俩个部分来说:

内部特性:

  • 本地命中宿主缓存 Json。这块主要是需要个人去维护,去抓去目前常用的一个 App 的相关信息,不断完善。

外部特性:

  • 通道名称。这个理解起来比较容易,好比你拿着 A 小区的通行证进入 B 小区,那么 B 小区的保安大叔肯定会给你拦下来,而反之你进入 A 小区则畅行无阻。

  • 对外暴露方法。比如说我现在对外暴露俩个方法,一个为检测命中宿主数量一个为实际的微信分享。

  • 关键参数描述。例如微信分享类型,目前偷个懒,Flutter 调用时只需要传递 bool 类型即可,SDK 内部会自行匹配。

针对以上内容,这里提取配置类: 

package com.hlq.struggle.app/** * @author:HLQ_Struggle * @date:2020/6/27 * @desc: *//** * 通道名称 */const val channelName = "HLQStruggle"/** * 检测命中数量 > 0 代表可采用命中宿主方案借壳分享 */const val checkAppInstalledChannel = "checkAppInstalled"/** * 分享微信 */const val shareWeChatChannel = "shareWeChat"/** * 分享微信消息会话 */const val shareWeChatSession = 0/** * 分享微信朋友圈 */const val shareWeChatLine = 1/** * 本地缓存 App 信息 */const val appInfoJson =        "[{\"appName\":\"App Name\",\"downloadUrl\":\"\",\"optional\":1,\"packageName\":\"Package Name\",\"packageSign\":\"App WeChat ID\",\"type\":1}]"

下面则是本地工具类,拼接参数,发送微信:

package com.hlq.struggleimport com.hlq.struggle.app.*import com.hlq.struggle.utils.ShareWeChatUtils.Companion.checkAppInstalledimport com.hlq.struggle.utils.ShareWeChatUtils.Companion.shareWeChatimport io.flutter.embedding.android.FlutterActivityimport io.flutter.embedding.engine.FlutterEngineimport io.flutter.plugin.common.MethodCallimport io.flutter.plugin.common.MethodChannelimport io.flutter.plugins.GeneratedPluginRegistrantclass MainActivity: FlutterActivity() {    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {        GeneratedPluginRegistrant.registerWith(flutterEngine)        // 处理 Flutter 传递过来的消息        handleMethodChannel(flutterEngine)    }    private fun handleMethodChannel(flutterEngine: FlutterEngine) {        MethodChannel(flutterEngine.dartExecutor, channelName).setMethodCallHandler { methodCall: MethodCall, result: MethodChannel.Result? ->            when (methodCall.method) {                checkAppInstalledChannel -> { // 获取命中 App 数量                    result?.success(checkAppInstalled(activity))                }                shareWeChatChannel -> {  // 分享微信                    val shareType = if (methodCall.argument("isScene")!!) {                        shareWeChatSession                    } else {                        shareWeChatLine                    }                    result?.success(shareWeChat(                            this, shareType,                            methodCall.argument("shareUrl")!!,                            methodCall.argument("shareTitle")!!,                            methodCall.argument("shareDesc")!!,                            methodCall.argument("shareThumbnail")!!, ""))                }                else -> {                    result?.notImplemented()                }            }        }    }}

5. Flutter 端调用

这里个人习惯,首先定义一个常量类,将 SDK 或者说 Android 端插件暴露参数定义一下,使用时统一调用,方便然后维护。

/// @date 2020-06-27/// @author HLQ_Struggle/// @desc 常量类/// 通道名称const String channelName = 'HLQStruggle';/// 检测命中数量 > 0 代表可采用命中宿主方案借壳分享const String checkAppInstalled = 'checkAppInstalled';/// 分享微信const String shareWeChat = 'shareWeChat'; 

而对于 Flutter 调用 Android 原生则比较 easy 了,相关注意的点已在代码中注释,这里直接附上对应的关键代码:

class _MyHomePageState extends State {  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text(widget.title),      ),      body: Center(        child: Column(          mainAxisAlignment: MainAxisAlignment.center,          children: [            GestureDetector(              onTap: () async {                _shareWeChatApp(true);              },              child: Text(                '点我分享微信消息会话',              ),            ),            GestureDetector(              onTap: () async {                _shareWeChatApp(false);              },              child: Padding(                padding: EdgeInsets.only(top: 30),                child: Text(                  '点我分享微信朋友圈',                ),              ),            )          ],        ),      ),    );  }  /// 具体分享微信方式:true:消息会话 false:朋友圈  /// 提前调取通道验证采用官方 SDK 还是借壳方案  void _shareWeChatApp(bool isScene) async {    /// 这里一定注意通道名称俩端一致    const platform = const MethodChannel(channelName);    int tempHitNum = 0;    try {      tempHitNum = await platform.invokeMethod(checkAppInstalled);    } catch (e) {      print(e);    }    if (tempHitNum > 0) {      // 当前设备存在目标宿主 - 开始执行分享      await platform.invokeMethod(shareWeChat, {        'isScene': isScene,        'shareTitle': '我是分享标题',        'shareDesc': '我是分享内容',        'shareUrl': 'https://juejin.im/post/5eb847e56fb9a0438e239243',        /// 分享内容在线地址        'shareThumbnail':            'https://user-gold-cdn.xitu.io/2018/9/27/16618fef8bbf66fb?imageView2/1/w/180/h/180/q/85/format/webp/interlace/1'        /// 分享图片在线地址      });    } else {      // 当前设备不存在目前宿主    }  }}

好了,整个一个流程完成了。我们看下最后实际分享的效果:

6. 查看效果

分享微信消息会话

f666410201aa8d6aacc7d221e90cd83a.png

分享成功提示,重点在分享来源:

3fc913aceebb062d5d4b59c49a2dca77.png

分享微信消息会话,来源成功变成了我梦想殿堂旗下的某个 App 了。

而分享朋友圈则比较简单了:

69c336373bc719e96dc3854da22db8bb.png

番外 - 瞎叨叨

说实话,这个东西不难。

但是磕磕巴巴搞了好几天,也被各种催,甚至差点掏钱去买。

当我很开心的和鸡老大去分享这个事儿整个过程,除了鸡老大日常三连夸之外,老大默默说了个思路,问我是不是这样子的。

默默听完,蛋疼了半天,一模一样!

日常吹鸡老大,老大却淡淡的回复,很正常呀,巴拉巴拉~

老大,不愧是老大~

免责声明

为了避免收费的小哥哥干我,或者出现其它不好的情况,这里特意注明下:

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

Thanks

  • ClassyShark

  • Shrinking Your Build With No Rules and do it with Class(yShark)

  • JD-GUI

  • dex2jar

  • Writing custom platform-specific code:撰写双端平台代码(插件编写实现)

  • Flutter Plugin调用Native APIs 

欢迎各位关注

不定期发布

见证成长路

1ac7ff627eefd5aa52a7acfe2386e2e2.png26f43a0e750efc3b67f37b71aa486c19.pngf78f2654ebd304a4e014e31cf45dbdd9.gif觉得不错,右下角点个好看呗~416709a94c8cc3ef136f881ca22e8456.gif
Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐