????????本文主要是對 Telegram 7.9.3 的隨筆記載,在目前的版本上改動配置以正常編譯,簡單講解主界面UI流程、初始化、推送、加解密、前后端交互等核心功能,可能不夠準確,只是我目前所了解到的,歡迎指正錯誤和交流。
????????對于比較流行的IM開源項目,后續(xù)我會再針對性地重新整理一下Signal和wire項目(曾經在這兩項目上二次開發(fā)過,這里是wire舊版本環(huán)境搭建+項目運行),針對最新版本再總結一下。
????????在打開項目之前,先看一下 gradle.properties文件,把 org.gradle.jvmargs 改到足夠大,建議6G以上,否則你會發(fā)現有些類在滑動代碼時一卡一卡的,下面先針對不同的2個AS版本修改配置以確保項目編譯成功。
在安裝了最新版本AS(Android Studio Arctic Fox | 2020.3.1 Patch)的情況下,你可能需要改動的地方如下:
- gradle/wrapper/gradle-wrapper.properties 文件:
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
gradle 插件版本跟gradle版本對照
由于我本地安裝的SDK31的版本無法使用,于是將版本改為30,后續(xù)可能不需要做跟SDK版本相關的操作(下面所有操作):
TMessagesProj/build.gradle 文件:
compileSdkVersion 30
buildToolsVersion '30.0.0'
Dockerfile 文件:
ENV ANDROID_API_LEVEL android-30
ENV ANDROID_BUILD_TOOLS_VERSION 30.0.0
ENV ANDROID_VERSION 30
RUN cp $ANDROID_HOME/build-tools/30.0.3/dx $ANDROID_HOME/build-tools/30.0.0/dx
RUN cp $ANDROID_HOME/build-tools/30.0.3/lib/dx.jar $ANDROID_HOME/build-tools/30.0.0/lib/dx.jar
刪除目錄 values-v31/
chats_widget_info.xml 文件:
刪掉
targetCellWidth、targetCellHeight、previewLayout、widgetFeatures
contacts_widget_info.xml 文件:
刪掉
targetCellWidth、targetCellHeight、previewLayout、widgetFeatures、maxResizeHeight
啟動項目,可正常編譯通過。
在安裝較低版本的AS(以4.0.1為例)情況下,需要改動的地方如下:
build.gradle 文件:
classpath 'com.android.tools.build:gradle:4.0.1'
TMessagesProj/build.gradle 文件:
ndk.debugSymbolLevel = 'FULL'
4.1 之后 release 版本攜帶未精化的本地庫配置。全部屏蔽掉
關于SDK31的地方參照上面方法,另外需要額外修改 Dockerfile 文件:
FROM gradle:6.5-jdk11
至此項目可正常運行,如果res或xml有異常請按照提示來屏蔽或刪除。
????????第一次編譯可能比較耗時,慢慢等待。我個人推薦用最新版本的AS,用項目自帶的gradle插件版本(4.2.1)和gradle版本(6.7.1),因為不同gradle插件版本對應的語法可能有變動,最好跟著原項目來。
說一下個人對這個項目的感受
????????首先項目代碼量確實比較大,以前未見過一個類里面寫了5w+行代碼(這是個model類,我們一般不這么搞,這點得吐槽一下,把AS搞卡了),一個聊天界面可以寫2w+行代碼。估算了一下org.telegram包下代碼量大概50w行,這個包下沒有第三方代碼估算比較切合實際。做過一些項目之后,會發(fā)現一個功能齊全、穩(wěn)定迭代的IM項目可能只需要15w行java代碼就能搞定(估算了一下Signal的代碼大概19w行,如果把Signal代碼改成Kotlin可能只需要8w行左右)。之所以Telegram 的上層代碼量如此多,主要是因為其中幾乎所有的UI控件都是手動寫出來的,剩下的是因為他們手動寫不了(通知欄和widget)。Talegram debug版本給我的體驗已經非常好了,動效很炫酷、流暢,內存整體上不超過400M,單賬號登錄不再操作UI時一段時間后會降到170M以下,值得我們研究學習。接下來由淺入深,講一下具體功能。
UI 層次
????????簡而言之,在一個Activity中通過ActionBarLayout添加、移除不同視圖(BaseFragment——一個普通類)來實現UI切換,ActionBarLayout通過管理BaseFragment來讓其具有類似系統(tǒng)Fragment生命周期的功能。
????????當Activity需要展示某個BaseFragment時,流程如下:
ActionBarLayout#presentFragment-->BaseFragment#createView-->BaseFragment#onResume
????????當Activity需要移除某個BaseFragment時,流程如下:
ActionBarLayout#removeFragmentFromStackInternal、ActionBarLayout#closeLastFragment
--> BaseFragment#onPause --> BaseFragment#onFragmentDestroy
下面是主要UI 圖層結構(手機版):

透過UI看本質,程序初始化做了些什么呢?先看上層GcmPushListenerService
????????為了避免誤解,我們先設置UserConfig.MAX_ACCOUNT_COUNT = 1 ,因為不論有多少賬號,推送入口只有一個,gcmPushToken 也只有一個。找到 ApplicationLoader#initPlayServices,在接收到Firebase返回的 gcmPushToken 之后,客戶端會將其上傳至IM服務器,這個過程結束之后,部分屬性會被賦值:
SharedConfig.pushAuthKey = random byte[256];
SharedConfig.pushString = gcmPushToken;
registeredForPush = true;
// 第一次收到推送消息時,下面數據會被賦值
SharedConfig.pushAuthKeyId = new byte[8];
byte[] authKeyHash = Utilities.computeSHA1(SharedConfig.pushAuthKey);
System.arraycopy(authKeyHash, authKeyHash.length - 8, SharedConfig.pushAuthKeyId, 0, 8);
有了這些條件,就可以解密后續(xù)推送過來的數據。
Gcm推送的整體流程如下:

數據庫初始化
在ApplicationLoader#getFilesDirFixed定義了項目中的緩存文件(下載的緩存文件,本地配置文件.dat,主題資源映射*.attheme,數據庫.db、文本資源等)。ApplicationLoader#onCreate --> MessagesController#getInstance --> BaseController#getMessagesStorage -->
MessagesStorage#getInstance --> MessagesStorage#openDatabase 即開始建庫建表。
一共 UserConfig.MAX_ACCOUNT_COUNT 個庫,路徑分別為 data/data/{pkg}/files、data/data//{pkg}/files/account1、data/data//{pkg}/files/account2 ……
至此,打開項目時就會創(chuàng)建UserConfig.MAX_ACCOUNT_COUNT個數據庫。
socket 初始化
????????由于 Telegram 的網絡層全部在 C++ 里面(主要涉及到 ConnectionsManager.cpp、Connection.cpp、ConnectionSocket、TgNetWrapper.cpp、Datacenter.cpp、Handshake.app、EventObject.cpp等文件),這里就從頭開始梳理一下底層初始化過程。在上層調用System.loadLibrary(xx_so)時,會走到對應 so 的 JNI_OnLoad 函數,本項目則是 jni::JNI_OnLoad,緊接著里面會執(zhí)行 registerNativeTgNetFunctions函數,我們找到它的實現:
extern "C" int registerNativeTgNetFunctions(JavaVM *vm, JNIEnv *env) {
// 檢查底層所需要的上層方法是否聲明(包括 ConnectionsManager#native_init),
// 主要是通過反射找方法(static 和 native 方法),找不到則報錯
}
socket的初始化分2步
1.檢查所需要的上層方法是否聲明:
ApplicationLoader#onCreate --> NativeLoader#initNativeLibs
--> jni::JNI_OnLoad --> TgNetWrapper::registerNativeTgNetFunctions
至此底層函數跟上層方法映射完畢
2.初始化:
ApplicationLoader#onCreate --> ConnectionsManager#getInstance
--> ConnectionsManager#init --> ConnectionsManager#native_init
--> TgNetWrapper::init --> ConnectionsManager::init
--> ConnectionsManager::ThreadProc --> ConnectionsManager::select
至此底層網絡模塊初始化完畢,一共會創(chuàng)建 UserConfig.MAX_ACCOUNT_COUNT個ConnectionsManager.cpp 對象 和ConnectionsManager.java 對象。ConnectionsManager::select 函數會給每個賬號創(chuàng)建一個死循環(huán)監(jiān)聽 socket fd 的狀態(tài),并分發(fā)數據。
Handshake過程——交換 nonce、 p、q ,前后端生成 aesKey、aesIv,為后續(xù)在線socket內容加解密。
????????首先是Client C 向 Server S發(fā)送隨機數 nonce ,然后S接收到 nonce后給C 發(fā)送隨機數 server_nonce 和大質數 pq,C分解 pq 得到 p、q,然后用RSA 公鑰加密 nonce 、server_nonce 、p、q并發(fā)送 ,S通過RSA 私鑰解密數據并返回 encrypted_answer 給C(雖然S 代碼未開源,但是可以根據C代碼推理出主要實功能),C 解密 encrypted_answer,本地計算 authKey 和臨時的 aesKey 、aesIv,用臨時 key 和 iv 加密數據發(fā)送給S,S 接收解密之后再次回復C后表示握手完畢。此時 C 的authKey 即為后續(xù)請求(消息)的AES 加密秘鑰的變量之一(另一個變量為請求數據的SHA256),所有上層數據(一般請求、消息發(fā)送和私密私聊)都會在底層加密一次。可以推理出 S 的 aesKey 和 aesIv 跟 C 的生成方式一樣。參考下圖:

SecretChatHelper
????????普通私聊的升級版,會話id為普通私聊的id左移32位,需要彼此確認才能建立,類似握手過程,創(chuàng)建上層數據加密的 authKey。
#sendRequestKeyMessage
#sendAcceptKeyMessage
#updateEncryptedChat
加解密
????????關于底層加解密實現可以參考上層生成aesKey、aesIv過程MessageKeyData#generateMessageKeyData。底層代碼我暫時不方便解釋,避免誤導。下面是Socket發(fā)收消息的加解密相關代碼:

???????? 聯(lián)想到一般的請求發(fā)給服務器之后,服務器接收需要解密,同時結合上圖解密過程和推送消息的解密過程,可以看出服務端轉發(fā)給客戶端的消息中,在線和離線的消息加密的秘鑰不一致,所以服務端解密了客戶端的socket消息后,會根據對方是否在線用握手時生成的秘鑰或者推送的pushAuthKey重新加密,然后發(fā)送(推送)給接收者。這次總結先寫到這,具體細節(jié)后續(xù)有空再慢慢研究。
Tips:
大家可以把這個宏——#define DEBUG_VERSION打開查看底層日志。