Flutter Notes | Android 借殼分享微信

HLQ_Struggle發表於2020-06-27
每個生命體的存在,其實本質都是一個複雜的過程。很多時候,無需追求完美的理想情況,畢竟,You are just you。

在這裡插入圖片描述

免責宣告

為了避免收費的小哥哥幹我,或者出現其它不好的情況,這裡特意註明下:

本文如同標題一樣,只屬於個人筆記,僅限技術分享~ 如出現其他情況,一概與本人無關~

本文如同標題一樣,只屬於個人筆記,僅限技術分享~ 如出現其他情況,一概與本人無關~

本文如同標題一樣,只屬於個人筆記,僅限技術分享~ 如出現其他情況,一概與本人無關~

前言

前段時間,公司突然來一需求:

  • 調研某款 App Android 版微信分享來源動態原理以及實現方式

第一時間,當然是看看網上有沒有前輩開源,借鑑(CV 大法)一波。

查詢結果真的是悲喜交加:

  • 開森的是,有人研究過這個東西,也封裝好了對應的 SDK。
  • 悲劇的是收費,目前已瞭解的情況最低 100。

對於本身在帝都討生活的落魄小 Android 而言,無疑是一筆鉅款 (手動滑稽~勿噴~)

都說窮人家的孩子早當家,不得已開始了逆向、分析之路 ???

相關程式碼已上傳 GitHub,當然為了不給自己找事兒,本地命中庫就不提供了,自己逆向去拿吧,地址如下:

效果圖

空談無用,來個實際效果圖最棒,這裡就以我夢想殿堂 App 為例進行測試咯。

準備工具

基於個人瞭解簡單概述:

  • ApkTools: 一般就是為了改包、回包,捎帶腳拿個資原始檔。
  • ClassyShark: 一款賊方便分析 Apk 工具,一般用於看看大廠都玩啥。
  • dex2jar: 將 .dex 檔案轉換為 .class 檔案。
  • JD-GUI: 主要是檢視反編譯後的原始碼。

下面附上相關工具網盤連結:

實戰開搞

在正式開始前,先來見識下 ClassyShark 這個神器吧。

一、Hi,ClassyShark

首先進入你下載好的 ClassyShark.jar 目錄中,隨後執行如下命令即可:

  • java -jar ClassyShark.jar

示意圖如下:

隨後在開啟的視覺化工具中將想看的 Apk 直接拖進去即可:

拖進去之後點選包名,會有一個對當前 Apk 的簡單概述:

點選 Methods count 可以檢視當前 Apk 方法數:

當然你可以繼續往下一層級檢視,比如我點選 bilibili:

同樣也可以匯出檔案,這裡不作為本文重點闡述了,有興趣的可以自己研究~

二、逆向分析走起

首先,網上下載目標 App,並將字尾名修改為 zip,隨後解壓進入該目錄:

手動進入已下載完成的 dex-tools-2.1-SNAPSHOT 目錄中,執行如下命令:

  • sh d2j-dex2jar.sh [目標 dex 檔案地址]

例如:

完成之後,將會在 dex-tools-2.1-SNAPSHOT 目錄中生成 classes-dex2jar.jar 檔案,這裡檔案就是我們接下來逆向分析的靠山吶。

隨後將生成的 jar 檔案拖入 JD-GUI 中。

檢視 AndroidManifest 獲取到當前應用包名,有助於我們一步到位~

由於目標 App 是在文章的詳情頁中提供分享微信訊息回話以及朋友圈,詳情一般個人命名為 XxxDetailsActivity,根據這個思路去搜尋。

有些尷尬啊,怎麼搜尋到了騰訊的 SDK 呢?

還是手動人工查詢吧,???

在這塊發現個比較有意思的東西,可能是我比較 low 吧。一般而言,我們都知道混淆實體類是肯定不能被混淆的,不然就會出現找不到的情況。那麼奇怪了,昨天逆向 B 站 Apk,我竟然沒發現實體類,難道他們的實體類有其他神操作?還是說分包太多我沒找到?

終於找到你,文章詳情頁!!!

操作 App,發現是點選按鈕彈出底部分享對話方塊,原版如下:

隨後繼續在程式碼中檢視,果然:

這個就很好理解了,自定義一個底部對話方塊,點選傳遞分享的 Url 以及分享型別。現在我們去 ShareArticleDialog 這個類中驗證一下猜想是否正確?

看,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 中也發現類似程式碼,由於掘金這個上傳圖片寬高我現在還不會調整,暫時防止目錄位置,感興趣的小夥伴自行檢視:

其它細節就不一一分析了,直接上程式碼咯~

三、附上程式碼~

其實本質借殼分享,個人的理解如下:

  • 第一步:繞過微信檢測,例如包名、簽名是否和微信開放平臺繫結一致;
  • 第二部:組裝引數,直接直擊深處,分享微信。

由於此次是 Flutter 專案,不得不的面對的是與原生 Android 的互動。由於我是剛剛入坑 Flutter 幾周,內心真的是忐忑不安。

不過值得讓人讚歎的是,Flutter 的生態,真的賊棒!尤其我雞老大,神一般存在!默默的感謝我大哥~!

0. 簡單聊下 Flutter 與互動

在 Flutter 中文社群中官網對此有這樣的一段描述:

Flutter 使用了靈活的系統,它允許你呼叫相關平臺的 API,無論是 Android 中的 Java 或 Kotlin 程式碼,還是 iOS 中的 Objective-C 或 Swift 程式碼。

Flutter 內建的平臺特定 API 支援不依賴於任何生成程式碼,而是靈活的依賴於傳遞訊息格式。或者,你也可以使用 Pigeon 這個 >
package,通過生成程式碼來傳送結構化型別安全訊息。

  • 應用程式中的 Flutter 部分通過平臺通道向其宿主(應用程式中的 iOS 或 Android 部分)傳送訊息。
  • 宿主監聽平臺通道並接收訊息。然後,它使用原生程式語言來呼叫任意數量的相關平臺 API,並將響應傳送回客戶端(即應用程式中的 Flutter 部分)。

也就是說,Flutter 充分給予我們呼叫原生 Api 的權利,關鍵橋樑便是這個通道訊息。

下面一起來看下官方的圖:

訊息和響應以非同步的形式進行傳遞,以確保使用者介面能夠保持響應。

客戶端做方法呼叫的時候 MethodChannel 會負責響應,從平臺一側來講,Android 系統上使用 MethodChannelAndroid、 iOS 系統使用 MethodChanneliOS 來接收和返回來自 MethodChannel 的方法呼叫。

其實對於我一個新手而言,看這些真的似懂非懂,所以過多的等以後掌握了之後再來探討吧。這塊內容將在下面程式碼部分著重說明。

1. 引入三方庫

api 'com.tencent.mm.opensdk:wechat-sdk-android-without-mta:+'
// 主要用於將分享的線上圖片轉換為 Bitmap
implementation '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.utils

import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.hlq.struggle.app.appInfoJson
import com.hlq.struggle.bean.AppInfoBean
import com.tencent.mm.opensdk.modelmsg.SendMessageToWX
import com.tencent.mm.opensdk.modelmsg.WXMediaMessage
import com.tencent.mm.opensdk.modelmsg.WXMediaMessage.IMediaObject
import com.tencent.mm.opensdk.modelmsg.WXWebpageObject
import java.io.ByteArrayOutputStream
import java.io.IOException

/**
 * @author:HLQ_Struggle
 * @date:2020/6/27
 * @desc:
 */
@Suppress("SpellCheckingInspection")
class ShareWeChatUtils {

    companion object {

        /**
         * 解析本地快取 App 資訊
         */
        private fun getLocalAppCache(): ArrayList<AppInfoBean> {
            return Gson().fromJson(
                    appInfoJson,
                    object : TypeToken<ArrayList<AppInfoBean>>() {}.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<Bitmap?> {
                        override fun onLoadFailed(
                                param1GlideException: GlideException?,
                                param1Object: Any,
                                param1Target: Target<Bitmap?>,
                                param1Boolean: Boolean
                        ): Boolean {
                            LogUtils.logE(" ---> Load Image Failed")
                            return false
                        }

                        override fun onResourceReady(
                                param1Bitmap: Bitmap?,
                                param1Object: Any,
                                param1Target: Target<Bitmap?>,
                                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.struggle

import com.hlq.struggle.app.*
import com.hlq.struggle.utils.ShareWeChatUtils.Companion.checkAppInstalled
import com.hlq.struggle.utils.ShareWeChatUtils.Companion.shareWeChat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant

class 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<Boolean>("isScene")!!) {
                        shareWeChatSession
                    } else {
                        shareWeChatLine
                    }
                    result?.success(shareWeChat(
                            this, shareType,
                            methodCall.argument<String>("shareUrl")!!,
                            methodCall.argument<String>("shareTitle")!!,
                            methodCall.argument<String>("shareDesc")!!,
                            methodCall.argument<String>("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<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            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. 檢視效果

  • 分享微信訊息會話

image.png

分享成功提示,重點在分享來源:

image.png

分享微信訊息會話,來源成功變成了我夢想殿堂旗下的某個 App 了。

而分享朋友圈則比較簡單了:

image.png

番外 - 瞎叨叨

說實話,這個東西不難。

但是磕磕巴巴搞了好幾天,也被各種催,甚至差點掏錢去買。

當我很開心的和雞老大去分享這個事兒整個過程,除了雞老大日常三連誇之外,老大默默說了個思路,問我是不是這樣子的。

默默聽完,蛋疼了半天,一模一樣!

日常吹雞老大,老大卻淡淡的回覆,很正常呀,巴拉巴拉~

老大,不愧是老大~

免責宣告

為了避免收費的小哥哥幹我,或者出現其它不好的情況,這裡特意註明下:

本文如同標題一樣,只屬於個人筆記,僅限技術分享~ 如出現其他情況,一概與本人無關~

本文如同標題一樣,只屬於個人筆記,僅限技術分享~ 如出現其他情況,一概與本人無關~

本文如同標題一樣,只屬於個人筆記,僅限技術分享~ 如出現其他情況,一概與本人無關~

Thanks

相關文章