Android 使用Linphone SDK开发SIP客户端

简介: Android 使用Linphone SDK开发SIP客户端

平台


RK3288 + Linphone 5.1.0 + Android Studio


概述


      简单来说, 有了解过互联网电话服务或IM(即时消息)功能的.一般都会接触到VOIP和SIP, 实现即时通讯, 发文本消息也好话音通话也好, 甚至于视频通话.

关于SIP(Session Initiation Protocol,会话初始协议)

      VoIP是一个广义术语,可用于描述任何互联网电话服务,从低成本的住宅服务到企业统一通信工具的复杂实现。VoIP是一个可以用来描述任何基于Internet的电话服务的术语,而SIP是一种用于大多数类型VoIP部署的通信协议。


      在早期开发android的SIP 客户端的时候, 常常可以看到sipdroid的身影, 在前面的文章中已经有提及过并使用测试过, 只是这个项目目前来看, 只能用于做做DEMO, 简单的测试一些功能, 如注册, 登陆, 发文本消息之类的, 项目的推进/更新也不积极,

|-- 尝试过另外两个项目:

  |–csipsimple: 不好用/不会用

  |–abto_sip: 可测试用, 某些平台崩溃,官方付费


最终采用了Linphone

      自 2001 年作为第一个在 Linux 上使用 SIP 的开源应用程序推出以来,Linphone 已经变得非常流行,尤其是在开源社区中。 我们的工程团队一直致力于 Linphone 项目,以支持最流行平台的最新版本,并提供高级语音/视频和即时消息功能。

image.png


linphone-android客户端源码

linphone-SDK


使用Linphone-sdk打造一个SIP客户端


Gradle 版本

image.png

从linphone-SDK下载对应SDK并添加依赖

当前使用的版本是 linphone-sdk-android-5.1.0-beta.aar


build.gradle


dependencies {
    implementation files('libs/linphone-sdk-android-5.1.0-beta.aar')
}


SipPhone.java : 初始化


import org.linphone.core.Account;
import org.linphone.core.AuthInfo;
import org.linphone.core.Call;
import org.linphone.core.CallParams;
import org.linphone.core.Config;
import org.linphone.core.Core;
import org.linphone.core.CoreListener;
import org.linphone.core.CoreListenerStub;
import org.linphone.core.Factory;
public class SipPhone extends IPhone {
    Factory factory;
    Core core;
    AuthInfo user;
    AccountParams accountParams;
    Call currentCall;
  //初始化Factory, 在APP启动时调用.
    public static void loadSipLibs(){
        Factory.instance();
    }
    void initSip(Activity activity){
        Logger.i(TAG, "initSip");
        factory = Factory.instance();
        core = factory.createCore(null, null, activity);
        core.addListener(coreListener);
        //配置视频通话
        core.enableVideoCapture(true);
        core.enableVideoDisplay(true);
        core.getVideoActivationPolicy().setAutomaticallyAccept(true);
  //音频部分, 这里增加了一个遍历, 用于设置指定的音频格式.
        //h264, no VP8 fixed outgoing call no video.
        PayloadType[] payloads = core.getVideoPayloadTypes();
        for(int i = 0; i < payloads.length; i ++){
            //Payload:null, VP8/90000/0, A VP8 video encoder using libvpx library., VP8
            //Payload:profile-level-id=42801F, H264/90000/0, A H264 encoder based on MediaCodec API., H264
            PayloadType pt = payloads[i];
            //判断是否指定的音频格式.
            boolean goodPayload = PREFER_PAYLOAD.equals(pt.getMimeType());
            pt.enable(goodPayload);
        }
        //https://github.com/BelledonneCommunications/linphone-android/issues/1153
        //https://blog.csdn.net/AdrianAndroid/article/details/70048040
        //do not working
        //H264Helper.setH264Mode(H264Helper.MODE_AUTO, core);
        //回声消除, 与音频增益.
        //Logger.d(TAG, "initSip Cancellation=" + core.echoCancellationEnabled());
        Logger.d(TAG, "initSip getMicGainDb=" + core.getMicGainDb());
        Logger.d(TAG, "initSip PlaybackGainDb=" + core.getPlaybackGainDb());
        //core.enableEchoCancellation(true);
        Logger.d(TAG, "initSip finish Cancellation=" + core.echoCancellationEnabled());
    }
}


SipPhone.java : 登陆


void login(){
        i("login");
        String username = PreferenceUtils.getStringFromDefault(App.getApp(), App.PREF_VOIP_USER, "");
        String password = PreferenceUtils.getStringFromDefault(App.getApp(), App.PREF_VOIP_PWD, "");
        String domain = PreferenceUtils.getStringFromDefault(App.getApp(), App.PREF_VOIP_IP, "");
        String port = PreferenceUtils.getStringFromDefault(App.getApp(), App.PREF_VOIP_PORT, App.DEF_SIP_PORT);
        if(!StringTools.isNotEmpty(username, password, domain, port)){
            e("login failed: username(" + username + "), password(" + password + "), domain(" + domain + "), port(" + port + ")");
            return;
        }
        //sip:100@192.168.7.119:6060
        if(!domain.contains(":")){
            domain += ":" + port;
        }
        user = factory.createAuthInfo(username, null, password, null, null, domain, null);
        accountParams = core.createAccountParams();
        // A SIP account is identified by an identity address that we can construct from the username and domain
        String sipAddress = "sip:" + username + "@" + domain;
        Address identity = factory.createAddress(sipAddress);
        i("login for address " + sipAddress);
        accountParams.setIdentityAddress(identity);
        // We also need to configure where the proxy server is located
        Address address = factory.createAddress("sip:" + domain);
        // We use the Address object to easily set the transport protocol
        address.setTransport(TransportType.Udp);
        accountParams.setServerAddress(address);
        // And we ensure the account will start the registration process
        accountParams.setRegisterEnabled(true);
        // Asks the CaptureTextureView to resize to match the captured video's size ratio
        //core.getConfig().setBool("video", "auto_resize_preview_to_keep_ratio", true);
        // Now that our AccountParams is configured, we can create the Account object
        Account account = core.createAccount(accountParams);
        //account.setCustomHeader("Header1", "Header2");
        // Now let's add our objects to the Core
        core.addAuthInfo(user);
        core.addAccount(account);
        // Also set the newly added account as default
        core.setDefaultAccount(account);
        core.setUserAgent("User", "Agent");
        // Finally we need the Core to be started for the registration to happen (it could have been started before)
        core.start();
    }
    void logout(){
        i("logout");
        Account account = core.getDefaultAccount();
        if(account != null) {
            accountParams = account.getParams().clone();
            accountParams.setRegisterEnabled(false);
            account.setParams(accountParams);
        }
    }


SipPhone.java : 通话部分


//拨打电话.
    @Override
    public void call(String number, boolean video) {
        i("call " + number + " video(" + video + ")");
        String domain = PreferenceUtils.getStringFromDefault(App.getApp(), App.PREF_VOIP_IP, "");
        String port = PreferenceUtils.getStringFromDefault(App.getApp(), App.PREF_VOIP_PORT, App.DEF_SIP_PORT);
        // As for everything we need to get the SIP URI of the remote and convert it to an Address
        String remoteSipUri = "sip:" + toNumber + "@" + domain + ":" + port;
        Address remoteAddress = factory.createAddress(remoteSipUri);
        if(remoteAddress == null)return;
        // If address parsing fails, we can't continue with outgoing call process
        // We also need a CallParams object
        // Create call params expects a Call object for incoming calls, but for outgoing we must use null safely
        CallParams params = core.createCallParams(null);
        // We can now configure it
        // Here we ask for no encryption but we could ask for ZRTP/SRTP/DTLS
        params.setMediaEncryption(MediaEncryption.None);
        params.enableVideo(video);
        //show preview before caling.
        //core.enableVideoPreview(video);
        // Finally we start the call
        core.inviteAddressWithParams(remoteAddress, params);
        //回声消除
        // Call process can be followed in onCallStateChanged callback from core listener
    }
//挂断
    @Override
    public void hangup() {
        i("hangup");
        if (core.getCallsNb() == 0) return;
        // If the call state isn't paused, we can get it using core.currentCall
        Call call = core.getCurrentCall() != null ? core.getCurrentCall() : core.getCalls()[0];
        if(call != null) {
            // Terminating a call is quite simple
            call.terminate();
        }
    }
//接听/应答
    @Override
    public void answer() {
        i("answer");
        if(currentCall != null){
            if(remoteHasVideo()) {
                enableCamera();
                currentCall.getParams().enableVideo(true);
            }
            currentCall.accept();
        }
    }


SipPhone.java : 监听和回调


//在initSip中使用.
    CoreListener coreListener = new CoreListenerStub(){
        @Override
        public void onCallStateChanged(Core core, Call call, Call.State state, String message) {
            d("onCallStateChanged " + state);
            currentCall = call;
            if(state == Call.State.OutgoingProgress){
                //呼出
            }else if(state == Call.State.IncomingReceived){
                //来电
            }else if(state == Call.State.StreamsRunning){
                //通话中, 有音视频流.
            }else if(state == Call.State.UpdatedByRemote){
                //通话变化, 有可能变成语音, 也有可能是带视频...
            }else if(state == Call.State.Released){
                //挂电或结束通话
            }else if(state == Call.State.Error){
                //出错.
            }
        }
        @Override
        public void onRegistrationStateChanged(Core core, ProxyConfig proxyConfig, RegistrationState state, String message) {
            //message:
            // case "io error": server offline.
            //
            i("onRegistrationStateChanged " + state + " with msg:" + message);
            //((Button)findViewById(R.id.btLogin)).setText(state == RegistrationState.Ok ? "Logout":"Login");
            if(state == RegistrationState.Ok) {
                //登陆成功
            }else{
                //登出
            }
        }
    };


关于视频部分:

如何设置视频显示的控件, 在通话呼起后可以调用这个函数.


public void setVideoView(View v1, View v2){
        core.setNativePreviewWindowId(v1);
        core.setNativeVideoWindowId(v2);
    }


所有的功能接口, 请以参考源码及官方为主

强烈建议下载linphone-android客户端源码并编译运行, 学习如何更好地使用SDK开发自己需要的功能


配置文件


     在优化视频通话的过程中, 接触到关于初始化配置的问题. 很多资料显示, 可能通过配置方件的方式, 配置优化音频参数来优化通话效果:

Echo suppression does not work

Android音视频通话——Linphone开发笔记总结

2022-09-24-voice_communication_audio_codec.md


大致的方法是:


1.增加配置文件

assets/linphone_factory或 assets/linphonerc_factory

res/raw/linphone_factory 或 res/raw/linphonerc_factory


2.编写对应配置


[sip]
guess_hostname=1
register_only_when_network_is_up=1
auto_net_state_mon=1
auto_answer_replacing_calls=1
ping_with_options=0
use_cpim=1
zrtp_key_agreements_suites=MS_ZRTP_KEY_AGREEMENT_K255_KYB512
chat_messages_aggregation_delay=1000
chat_messages_aggregation=1
[sound]
#remove this property for any application that is not Linphone public version itself
ec_calibrator_cool_tones=1
# 打开回声消除
echocancellation=1
# MIC 增益
mic_gain_db=0.0
# 回放增益
playback_gain_db=0.0
[video]
displaytype=MSAndroidTextureDisplay
auto_resize_preview_to_keep_ratio=1
max_mosaic_size=vga


3.打包到程序中运行.

image.png

以上这些方法, 仅适用于linphone-android客户端源码, 针对基于SDK开发的话, 则需要在对应的地方加入载入配置文件的代码:

//参考
//-linphone-android/app/src/main/java/org/linphone/core/CorePreferences.kt
//-linphone-android/app/src/main/java/org/linphone/LinphoneApplication.kt
//在创建Core之前载入配置文件.
Config config = factory.createConfigWithFactory(App.LINPHONE_CONFIG_DEF, App.LINPHONE_CONFIG_FAC);
core = factory.createCoreWithConfig(config, App.getApp().getActivity());


参考


Ubuntu搭建简单SIP服务器并使用sipdroid测试

一文详解SIP 协议- xiaxueliang - 博客园

sipdroid


相关文章
|
4天前
|
JavaScript 搜索推荐 Android开发
【01】仿站技术之python技术,看完学会再也不用去购买收费工具了-用python扒一个app下载落地页-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-客户的麻将软件需要下载落地页并且要做搜索引擎推广-本文用python语言快速开发爬取落地页下载-优雅草卓伊凡
【01】仿站技术之python技术,看完学会再也不用去购买收费工具了-用python扒一个app下载落地页-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-客户的麻将软件需要下载落地页并且要做搜索引擎推广-本文用python语言快速开发爬取落地页下载-优雅草卓伊凡
23 8
【01】仿站技术之python技术,看完学会再也不用去购买收费工具了-用python扒一个app下载落地页-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-客户的麻将软件需要下载落地页并且要做搜索引擎推广-本文用python语言快速开发爬取落地页下载-优雅草卓伊凡
|
11天前
|
前端开发 安全 开发工具
【11】flutter进行了聊天页面的开发-增加了即时通讯聊天的整体页面和组件-切换-朋友-陌生人-vip开通详细页面-即时通讯sdk准备-直播sdk准备-即时通讯有无UI集成的区别介绍-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
【11】flutter进行了聊天页面的开发-增加了即时通讯聊天的整体页面和组件-切换-朋友-陌生人-vip开通详细页面-即时通讯sdk准备-直播sdk准备-即时通讯有无UI集成的区别介绍-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
141 90
【11】flutter进行了聊天页面的开发-增加了即时通讯聊天的整体页面和组件-切换-朋友-陌生人-vip开通详细页面-即时通讯sdk准备-直播sdk准备-即时通讯有无UI集成的区别介绍-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
|
14天前
|
Dart 前端开发 Android开发
【09】flutter首页进行了完善-采用android studio 进行真机调试开发-增加了直播间列表和短视频人物列表-增加了用户中心-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
【09】flutter首页进行了完善-采用android studio 进行真机调试开发-增加了直播间列表和短视频人物列表-增加了用户中心-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
37 4
【09】flutter首页进行了完善-采用android studio 进行真机调试开发-增加了直播间列表和短视频人物列表-增加了用户中心-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
|
16天前
|
前端开发 Java Shell
【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
121 20
【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
|
6月前
|
移动开发 搜索推荐 Android开发
安卓与iOS开发:一场跨平台的技术角逐
在移动开发的广阔舞台上,两大主角——安卓和iOS,持续上演着激烈的技术角逐。本文将深入浅出地探讨这两个平台的开发环境、工具和未来趋势,旨在为开发者揭示跨平台开发的秘密,同时激发读者对技术进步的思考和对未来的期待。
|
6月前
|
安全 Android开发 Swift
安卓与iOS开发:平台差异与技术选择
【8月更文挑战第26天】 在移动应用开发的广阔天地中,安卓和iOS两大平台各占一方。本文旨在探索这两个系统在开发过程中的不同之处,并分析开发者如何根据项目需求选择合适的技术栈。通过深入浅出的对比,我们将揭示各自平台的优势与挑战,帮助开发者做出更明智的决策。
104 5
|
6月前
|
移动开发 开发工具 Android开发
安卓与iOS开发之巅:探索两大移动平台的核心技术与创新趋势
【8月更文挑战第22天】 在移动应用开发的浩瀚星海中,安卓和iOS犹如两颗璀璨的星辰,各自绽放着独特的光芒。本文将深入剖析这两大平台的技术架构、开发工具、生态系统以及未来发展趋势,带领读者领略它们的魅力所在。无论你是安卓的忠实拥趸,还是iOS的铁杆粉丝,亦或是中立的开发者,这篇文章都将为你揭示一个多元而精彩的移动开发世界。让我们一起踏上这场技术之旅,感受安卓与iOS之间的碰撞与融合,共同见证移动开发的未来。
83 0
|
6月前
|
移动开发 开发工具 Android开发
探索安卓与iOS开发的差异:技术选择的影响
【8月更文挑战第17天】 在移动应用开发的广阔天地中,安卓和iOS两大平台各领风骚。本文通过比较这两个平台的编程语言、开发工具及市场策略,揭示了技术选择对开发者和产品成功的重要性。我们将从开发者的视角出发,深入探讨不同平台的技术特性及其对项目实施的具体影响,旨在为即将步入移动开发领域的新手提供一个清晰的指南,同时给予资深开发者新的思考角度。
78 3
|
6月前
|
编解码 Android开发 iOS开发
安卓与iOS开发:平台差异下的技术创新之路
在数字时代的浪潮中,移动应用开发如同两股潮流——安卓与iOS,各自携带着独特的技术生态和文化基因。本文将深入探讨这两大平台的开发环境、编程语言和工具的差异,以及它们如何塑造了不同的用户体验和技术趋势。通过比较分析,我们旨在揭示跨平台开发的可能性和挑战,同时探索未来技术创新的方向。让我们一起跟随代码的足迹,穿越安卓的开放草原和iOS的精密园林,发现那些隐藏在平台差异之下的创新机遇。
53 1
|
6月前
|
移动开发 Java Android开发
安卓与iOS开发:选择的艺术与技术的较量
在移动应用开发的广阔天地中,安卓和iOS两大平台各自占据着半壁江山。本文将探讨这两个平台在开发过程中的异同,以及它们如何影响开发者的选择。我们将从技术栈、市场份额、用户群体等方面进行分析,并结合案例来说明不同平台的优势与挑战。无论你是初涉移动开发领域的新手,还是经验丰富的老手,这篇文章都将为你提供有价值的见解。让我们一起探索这片充满机遇与挑战的土地吧!

热门文章

最新文章

  • 1
    【11】flutter进行了聊天页面的开发-增加了即时通讯聊天的整体页面和组件-切换-朋友-陌生人-vip开通详细页面-即时通讯sdk准备-直播sdk准备-即时通讯有无UI集成的区别介绍-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
  • 2
    【01】完整开发即构美颜sdk的uni官方uts插件—让所有开发者可以直接使用即构美颜sdk的能力-优雅草卓伊凡
  • 3
    如何修复 Android 和 Windows 不支持视频编解码器的问题?
  • 4
    Android历史版本与APK文件结构
  • 5
    【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
  • 6
    【04】flutter补打包流程的签名过程-APP安卓调试配置-结构化项目目录-完善注册相关页面-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程
  • 7
    当flutter react native 等混开框架-并且用vscode-idea等编译器无法打包apk,打包安卓不成功怎么办-直接用android studio如何打包安卓apk -重要-优雅草卓伊凡
  • 8
    APP-国内主流安卓商店-应用市场-鸿蒙商店上架之必备前提·全国公安安全信息评估报告如何申请-需要安全评估报告的资料是哪些-优雅草卓伊凡全程操作
  • 9
    【09】flutter首页进行了完善-采用android studio 进行真机调试开发-增加了直播间列表和短视频人物列表-增加了用户中心-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
  • 10
    【03】仿站技术之python技术,看完学会再也不用去购买收费工具了-修改整体页面做好安卓下载发给客户-并且开始提交网站公安备案-作为APP下载落地页文娱产品一定要备案-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-优雅草卓伊凡
  • 1
    【01】噩梦终结flutter配安卓android鸿蒙harmonyOS 以及next调试环境配鸿蒙和ios真机调试环境-flutter项目安卓环境配置-gradle-agp-ndkVersion模拟器运行真机测试环境-本地环境搭建-如何快速搭建android本地运行环境-优雅草卓伊凡-很多人在这步就被难倒了
    14
  • 2
    Cellebrite UFED 4PC 7.71 (Windows) - Android 和 iOS 移动设备取证软件
    28
  • 3
    【03】仿站技术之python技术,看完学会再也不用去购买收费工具了-修改整体页面做好安卓下载发给客户-并且开始提交网站公安备案-作为APP下载落地页文娱产品一定要备案-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-优雅草卓伊凡
    34
  • 4
    Android历史版本与APK文件结构
    121
  • 5
    【02】仿站技术之python技术,看完学会再也不用去购买收费工具了-本次找了小影-感觉页面很好看-本次是爬取vue需要用到Puppeteer库用node.js扒一个app下载落地页-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-优雅草卓伊凡
    29
  • 6
    【01】仿站技术之python技术,看完学会再也不用去购买收费工具了-用python扒一个app下载落地页-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-客户的麻将软件需要下载落地页并且要做搜索引擎推广-本文用python语言快速开发爬取落地页下载-优雅草卓伊凡
    23
  • 7
    APP-国内主流安卓商店-应用市场-鸿蒙商店上架之必备前提·全国公安安全信息评估报告如何申请-需要安全评估报告的资料是哪些-优雅草卓伊凡全程操作
    60
  • 8
    【09】flutter首页进行了完善-采用android studio 进行真机调试开发-增加了直播间列表和短视频人物列表-增加了用户中心-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
    37
  • 9
    当flutter react native 等混开框架-并且用vscode-idea等编译器无法打包apk,打包安卓不成功怎么办-直接用android studio如何打包安卓apk -重要-优雅草卓伊凡
    73
  • 10
    【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
    121