基于Android平台的APN加载流程分析

基于Android平台的APN加载流程分析

  • 1. APN介绍
    • 1.1 APN包含参数
  • 1.2 APN类型
    • 1.3 APN的存储位置与加载位置
      • 1.3.1 存储位置
      • 1.3.2 加载位置
  • 2 APN加载流程分析
    • 2.1 APN的创建:从XML到database
    • 2.2 APN匹配SIM卡与菜单显示
    • 2.3 PDP时APN选择
      • 2.3.1 createAllApnList
      • 2.3.4 setInitialAttachApn
      • 2.3.3 setupDataOnConnectableApns

1. APN介绍

Definition of Access Point Name
In the GPRS backbone, an Access Point Name (APN) is a reference to a GGSN. To support inter-PLMN roaming, the internal GPRS DNS functionality is used to translate the APN into the IP address of the GGSN.
——3gpp 23.003

从定义可看出,APN是GGSN的引用,被internal GPRS DNS转换为GGSN的IP地址。

在这里插入图片描述
GGSN全称Gateway GPRS Support Node, 网关GPRS支持节点。

GGSN主要起网关作用,所扮演的角色:

  • 对内:网络传输; (网络接入控制,分组数据的过滤)
  • 对外:路由器(路由选择和分组的转发,IP地址分配)

为了访问网络,手机必须设置合适的APN参数。APN的英文全称是Access Point Name,全称接入点,是手机上网时必现配置的参数。APN决定了用户的手机通过哪种接入方式来访问什么样的网络。

1.1 APN包含参数

一个典型的APN包含的参数有名称、MCCMNC、接入点、类型。除了这些基本参数,还包括其他参数如图所示:
在这里插入图片描述

1.2 APN类型

每个有数据业务的运营商都会设定自己的APN,同一个运营商的APN可能有多条,包括分别用于3G或4G,NET和WAP,不同APN的使用范围和收费会有差别。常见的APN类型有下面几种,用途和优先级各有不同。
在这里插入图片描述

1.3 APN的存储位置与加载位置

终端设备中有一个apns-config.xml文件,负责定义各个运营商规定的默认APN参数。

1.3.1 存储位置

APN在Android系统中以XML的形式存储
文件名:Apns-conf.xml
源码文件路径:

MTK平台(通常):alps\mediatek\frameworks\base\telephony\etc
高通平台(通常):android\vendor\qcom\proprietary\telephony-apps\etc
设备文件路径如图:system/etc/ apns-conf.xml

在这里插入图片描述
当设备开机后,读取XML中的APN并写入到database中。如图为apns-conf.xml一部分内容:
在这里插入图片描述

1.3.2 加载位置

  • 加载到database:TelephonyProvider读取XML并在database中插入apn的table。

  • 加载到UI菜单:根据SIM卡的MCCMNC,去匹配database中同样MCCMNC的APN项,并将匹配到的APN填写到菜单列表。

  • 加载到PDP请求:由DCtracker负责创建/更新waiting APN list,供PDP选用。

2 APN加载流程分析

2.1 APN的创建:从XML到database

从XML到database,通过telephonyprovider实现。

设备开机后,终端启动phone进程,会加载运行在phone进程中的telephonyprovider负责解析apns-config.xml文件,将其中定义的APN参数写入到数据库中。

在telephonyprovider中有initDatabase方法逻辑如下:

private void initDatabase(SQLiteDatabase db)
{1.打开APN xml文件etc/apns-conf.xml2. 获得文件句柄后,使用FileReader得到文件字符流3. 检查APN version一致性4.加载XMl中的数据到database,具体见loadApns方法
}
private void loadApns(SQLiteDatabase db, XmlPullParser parser) {if (parser != null) {try {db.beginTransaction();XmlUtils.nextElement(parser);while (parser.getEventType() != XmlPullParser.END_DOCUMENT) {ContentValues row = getRow(parser);if (row == null) {throw new XmlPullParserException("Expected 'apn' tag", parser, null);}insertAddingDefaults(db, row);XmlUtils.nextElement(parser);}db.setTransactionSuccessful();} catch (XmlPullParserException e) {loge("Got XmlPullParserException while loading apns." + e);} catch (IOException e) {loge("Got IOException while loading apns." + e);} catch (SQLException e) {loge("Got SQLException while loading apns." + e);} finally {db.endTransaction();}}}
  1. 每次读取parser中的一个element,也就是一条APN数据;
  2. 通过getrow方法将APN转换为Contentvalues;
  3. 最后通过insertAddingDefaults将键值写入database;

写入的table名是CARRIERS_TABLE,该table由createCarriersTable方法创建。

telephonyprovider的内部类DatabaseHelper在oncreate时创建APN table。DatabaseHelper类负责APN database的增删改查工作。

2.2 APN匹配SIM卡与菜单显示

设备插入SIM后,设置菜单中会显示该SIM卡对应的APN菜单。这部分在ApnSettings.java(android\packages\apps\settings\src\com\android\settings) 中的fillList方法实现的。该方法主要根据SIM卡的mccmnc去查询db并将APN填入菜单中。

2.3 PDP时APN选择

设备插入SIM卡后,telephony根据SIM卡的mccmnc,先创建一个包含该SIM卡所有APN的列表(AllApnList)。这一步在DcTracker中实现。Phone进程中的DcTracker构造方法如下:

  1. 调用父类DcTrackerBase的构造方法:
super(p);

DcTrackerBase的构造方法主要逻辑如下,监听SIM卡的变化:

mUiccController = UiccController.getInstance();
mUiccController.registerForIccChanged(this, DctConstants.EVENT_ICC_CHANGED, null);

也就是说,如果SIM卡有变化,UiccController会向DcTrackerBase发送EVENT_ICC_CHANGED消息,DcTrackerBase的handleMessage方法对该消息处理如下:

case DctConstants.EVENT_ICC_CHANGED:
{
onUpdateIcc();
break;
}

会回调DcTracker的onUpdateIcc方法。

  1. 注册telephony.db数据库的变化:
mApnObserver = new ApnChangeObserver();
p.getContext().getContentResolver().registerContentObserver(
Telephony.Carriers.CONTENT_URI, true, mApnObserver);
ApnChangeObserverDcTracker的内部类,定义如下:
private class ApnChangeObserver extends ContentObserver {public ApnChangeObserver () {super(mDataConnectionTracker);}@Overridepublic void onChange(boolean selfChange) {sendMessage(obtainMessage(DctConstants.EVENT_APN_CHANGED));}
}

而DcTrackerBase继承Handler,如下:

public abstract class DcTrackerBase extends Handler 

因此,一旦telephony.db数据库发生变化,会调用ApnChangeObserver 的onChange方法,然后调用Handler 的sendMessage方法发送EVENT_APN_CHANGED消息。DcTracker的handleMessage方法对该消息处理如下:

case DctConstants.EVENT_APN_CHANGED:onApnChanged();break;

因此, 一旦telephony.db数据库发生变化,就会调用DcTracker的onApnChanged方法。

  1. 调用initApnContexts方法初始化不同APN类型对应的网络:
initApnContexts();
  1. 其他
for (ApnContext apnContext : mApnContexts.values())
{// Register the reconnect and restart actions.IntentFilter filter = new IntentFilter();filter.addAction(INTENT_RECONNECT_ALARM + '.' + apnContext.getApnType());filter.addAction(INTENT_RESTART_TRYSETUP_ALARM + '.' + apnContext.getApnType());mPhone.getContext().registerReceiver(mIntentReceiver, filter, null, mPhone);
}
// Add Emergency APN to APN setting list by default to support EPDN in sim absent cases
initEmergencyApnSetting();
addEmergencyApnSetting();

DcTracker的onUpdateIcc方法主要逻辑如下:
调用IccRecords的registerForRecordsLoaded方法进行注册,监听卡信息是否载入完成。

if (newIccRecords != null)
{if (mPhone.getSubId() >= 0) {log("New records found.");mIccRecords.set(newIccRecords);newIccRecords.registerForRecordsLoaded(this, DctConstants.EVENT_RECORDS_LOADED, null);}
} 
else
{onSimNotReady();//SIM卡还未读出
}

如果SIM卡信息载入完成,则IccRecords会向DcTracker发送EVENT_RECORDS_LOADED,DcTracker的handleMessage对该消息处理如下:

case DctConstants.EVENT_RECORDS_LOADED:onRecordsLoadedOrSubIdChanged();break;

DcTracker的onRecordsLoadedOrSubIdChanged方法主要逻辑如下:

  1. 调用createAllApnList方法创建当前SIM卡的APN:
createAllApnList(); 
  1. 调用setInitialAttachApn方法设置初始使用的APN:
setInitialAttachApn();
  1. 调用setupDataOnConnectableApns方法发起拨号请求:
setupDataOnConnectableApns(Phone.REASON_SIM_LOADED);

当然,setupDataOnConnectableApns 方法也是通过调用trySetupData方法进行拨号的。如果当前SIM卡未打开数据业务,则不会拨号成功。

2.3.1 createAllApnList

createAllApnList函数主要根据卡信息,实际上是卡对应运营商的MCC/MCC,查询数据库,检索出所有匹配的数据;然后,生成并保存对应的ApnSetting。
DcTracker的createAllApnList方法调用流程图如下:

在这里插入图片描述
createAllApnList方法主要逻辑如下:

  1. 在telephony.db数据库的carriers表单中查询当前SIM卡对应的APN信息,
    并调用createApnList方法构造ApnSetting对象。如此,一般一个APN对应一个ApnSetting对象,这样就完成了数据库中APN到ApnSetting对象的映射。
Cursor cursor = mPhone.getContext().getContentResolver().query(
Telephony.Carriers.CONTENT_URI, null, selection, null, orderBy);
if (cursor != null) {if (cursor.getCount() > 0) {mAllApnSettings = createApnList(cursor, mIccRecords.get());}cursor.close();
}
  1. 添加emergencyApnSettings并删除重复的APN,
addEmergencyApnSetting();
dedupeApnSettings();
  1. 调用getPreferredApn方法从telephony.db数据库的siminfo表中读出默认的APN,一般第一次为空,如果该APN和当前卡的APN不匹配,则调用setPreferredApn方法删除telephony.db数据库的siminfo表中APN信息。
mPreferredApn = getPreferredApn(mAllApnSettings);
if (mPreferredApn != null && !mPreferredApn.numeric.equals(operator)) {
mPreferredApn = null;
setPreferredApn(-1);
}

setPreferredApn方法如下:

String subId = Long.toString(mPhone.getSubId());
Uri uri = Uri.withAppendedPath(PREFERAPN_NO_UPDATE_URI_USING_SUBID, subId);
log("setPreferredApn: delete");
ContentResolver resolver = mPhone.getContext().getContentResolver();
resolver.delete(uri, null, null); //首先调用delete方法删除if (pos >= 0) { //当前pos为 -1,因此后面的insert方法不执行。log("setPreferredApn: insert");ContentValues values = new ContentValues();values.put(APN_ID, pos);resolver.insert(uri, values); //插入默认的APN信息
}

createApnList方法如下:

if (cursor.moveToFirst()) {do {ApnSetting apn = makeApnSetting(cursor);•••if (apn.hasMvnoParams()) {if (r != null && ApnSetting.mvnoMatches(r, apn.mvnoType, apn.mvnoMatchData)) {mvnoApns.add(apn);//虚拟运营商}} else {mnoApns.add(apn); //运营商}} while (cursor.moveToNext());
}

利用cursor对象循环调用makeApnSetting方法构造ApnSetting对象,makeApnSetting方法就是利用cursor在telephony.db数据库的carriers表单中获取SIM卡对应的APN信息然后利用APN信息构造一个ApnSetting对象, makeApnSetting方法的部分代码如下:

String[] types = parseTypes(
cursor.getString(cursor.getColumnIndexOrThrow(Telephony.Carriers.TYPE)));
ApnSetting apn = new ApnSetting(
cursor.getInt(cursor.getColumnIndexOrThrow(Telephony.Carriers._ID)),
•••

ApnSetting 是一个比较轻量级的类,主要包含APN的信息,以及相关解析APN信息的方法。parseTypes方法如下:

String[] result;
// If unset, set to DEFAULT.
if (types == null || types.equals("")) {
result = new String[1];
result[0] = PhoneConstants.APN_TYPE_ALL;
} else {
result = types.split(",");
}
return result;

这段代码是解析APN的type字段。APN的type域,决定了它提供的网络能力。telephony.db数据库的carriers表单部分APN的type域如下:
在这里插入图片描述
APN type包含default时,利用这个APN建立的网络就具有Mobile能力,即能够用数据网络访问Internet;当APN type包含mms时,利用这个APN建立的网络就具有发送彩信的能力。从parseTypes函数可以看出,当APN的type为空时,即没有配置时,APN的type被定义为APN_TYPE_ALL。 利用APN_TYPE_ALL建立的网络,将具有全部的网络能力。

正常情况下,这种设计是合理的:
运营商会不同的服务定义不同的网络,于是通过APN的type域进行区分;但是,有的运营商在某些地区会用同一个网络支持所有的功能(例如在非洲的一些国家),此时将APN的type域写成”default, mms, supl, dun, hipri, fota, ims…….”是件繁琐的事,于是,就规定APN的type域为“”时,可以支持所有网络能力。然而,这种设计成为了Android的一个漏洞,在某些场景下,将带来数据连接无法断开的问题。

到此,已经在telephony.db数据库中查询到了SIM卡对应的APN信息,并且构造出了对应的ApnSetting对象。这些对象最终保存在DcTrackerBase的mAllApnSettings变量中,定义如下:

protected ArrayList<ApnSetting> mAllApnSettings = null;

以后很多地方都会使用到mAllApnSettings变量,例如拨号上网。并且默认APN信息保存在mPreferredApn变量中,定义如下:protected ApnSetting mPreferredApn = null;

2.3.4 setInitialAttachApn

setInitialAttachApn主要是从得到的所有ApnSetting中,选择一个用于初始时注册数据网络的APN,并将该APN下发给modem使用。
setInitialAttachApn方法调用流程图如下:
在这里插入图片描述
DcTrackerBase的setInitialAttachApn方法如下:

setInitialAttachApn(mAllApnSettings, mPreferredApn)

一般第一次开机时, mPreferredApn为null。mAllApnSettings为当前SIM卡的对应的APN。setInitialAttachApn方法主要逻辑如下:

  1. 为三个APN变量赋值:
    在这里插入图片描述
  2. 从4个ApnSetting 对象中选择一个进行发起拨号上网请求,优先级依次为iaApnSetting、preferredApn、defaultApnSetting、firstApnSetting:
    在这里插入图片描述
  3. 如果这4个值都为空,就不发起拨号请求;否则调用RIL的setInitialAttachApn方法发起拨号请求:
    在这里插入图片描述
    如图是一份开机拨号上网的log:

在这里插入图片描述

在这份log中, 选择的mPreferredApn对象的信息进行attach请求。RIL的setInitialAttachApn方法如下:

public void setInitialAttachApn(DataProfile dataProfile, boolean isRoaming, Message result) { IRadio radioProxy = getRadioProxy(result);if (radioProxy != null) {RILRequest rr = obtainRequest(RIL_REQUEST_SET_INITIAL_ATTACH_APN, result,mRILDefaultWorkSource);if (RILJ_LOGD) {riljLog(rr.serialString() + "> " + requestToString(rr.mRequest) + dataProfile);}try {if (mRadioVersion.greaterOrEqual(RADIO_HAL_VERSION_1_5)) {// v1.5android.hardware.radio.V1_5.IRadio radioProxy15 =(android.hardware.radio.V1_5.IRadio) radioProxy;radioProxy15.setInitialAttachApn_1_5(rr.mSerial,convertToHalDataProfile15(dataProfile));} else if (mRadioVersion.greaterOrEqual(RADIO_HAL_VERSION_1_4)) {// v1.4android.hardware.radio.V1_4.IRadio radioProxy14 =(android.hardware.radio.V1_4.IRadio) radioProxy;radioProxy14.setInitialAttachApn_1_4(rr.mSerial,convertToHalDataProfile14(dataProfile));} else {// v1.3, v1.2, v1.1, and v1.0radioProxy.setInitialAttachApn(rr.mSerial,convertToHalDataProfile10(dataProfile), dataProfile.isPersistent(),isRoaming);}} catch (RemoteException | RuntimeException e) {handleRadioProxyExceptionForRR(rr, "setInitialAttachApn", e);}}}

通过radioProxy.setInitialAttachApn给rild守护进程发送RIL_REQUEST_SET_INITIAL_ATTACH_APN消息。

在第4行使用obtainRequest将入参传入的Message生成一个RILRequest,且放入一个list中,他在list中的位置就是mSerial。这个RILRequest的编号就是RIL_REQUEST_SET_INITIAL_ATTACH_APN。在后面根据使用HIDL接口通知RILC设置INITIAL_ATTACH_APN,并将之前得到的mSerial传给RILC。

在这里插入图片描述

2.3.3 setupDataOnConnectableApns

当SIM卡加载完成,并完成上面2个步骤之后,就会调用DcTracker 的setupDataOnConnectableApns方法。setupDataOnConnectableApns方法调用流程图如下:

在这里插入图片描述
setupDataOnConnectableApns逻辑如下:

  1. 逐个循环调用mPrioritySortedApnContexts变量中的ApnContext对象,调用buildWaitingApns方法获取对应的ApnSetting对象:
for (ApnContext apnContext : mPrioritySortedApnContexts) {ArrayList<ApnSetting> waitingApns = null;••••waitingApns = buildWaitingApns(apnContext.getApnType(), radioTech);
  1. 如果该APN已激活,则调用trySetupData方法发起拨号
if (apnContext.isConnectable())
{log("setupDataOnConnectableApns: isConnectable() call trySetupData");apnContext.setReason(reason);trySetupData(apnContext, waitingApns);
}

trySetupData()函数主要做两件事:

  1. 判断APN状态是DctConstants.State.IDLE 的时候调用buildWaitingApns 构建拨号APN列表 并通过apnContext.setWaitingApns(waitingApns)将waitingApns列表设置到apnContext 中。

buildWaitingApns的代码看起来比较复杂,其实上就是有可用的prefer APN时,选择prefer APN;没有可用的prefer APN时,从现有卡对应的APN中,取出支持当前网络类型和无线技术的APN。

   if (apnContext.getState() == DctConstants.State.IDLE) {ArrayList<ApnSetting> waitingApns =buildWaitingApns(apnContext.getApnType(), radioTech);if (waitingApns.isEmpty()) {ApnSetting apn = apnContext != null ? apnContext.getApnSetting() : null;mPhone.notifyDataConnectionFailed(apnContext.getApnType(),apn != null ? apn.getApnName() : null,DataFailCause.MISSING_UNKNOWN_APN);String str = "trySetupData: X No APN found retValue=false";if (DBG) log(str);apnContext.requestLog(str);return false;} else {apnContext.setWaitingApns(waitingApns);if (DBG) {log ("trySetupData: Create from mAllApnSettings : "+ apnListToString(mAllApnSettings));}}}
  1. 调用setupData使用apn进行拨号连接。
boolean retValue = setupData(apnContext, radioTech, requestType);
if (DBG) log("trySetupData: X retValue=" + retValue);
return retValue;

如图是一份开机拨号上网的trySetupData相关log:
在这里插入图片描述
setupData最后的逻辑如下:

Message msg = obtainMessage();
msg.what = DctConstants.EVENT_DATA_SETUP_COMPLETE;
msg.obj = new Pair<ApnContext, Integer>(apnContext, generation);ApnSetting preferredApn = getPreferredApn();
boolean isPreferredApn = apnSetting.equals(preferredApn);
dataConnection.bringUp(apnContext, profileId, radioTech, msg, generation, requestType,
mPhone.getSubId(), isPreferredApn);

封装EVENT_DATA_SETUP_COMPLETE 消息,然后调用dataConnection的bringUp方法发起拨号过程。如果完成拨号过程,就会回调DcTracker的父类DcTrackerBase的handleMessage方法处理EVENT_DATA_SETUP_COMPLETE 消息,对该消息处理如下:

case DctConstants.EVENT_DATA_SETUP_COMPLETE:onDataSetupComplete((AsyncResult) msg.obj);break;

调用DcTracker的onDataSetupComplete方法,该方法会将调用setPreferredApn方法将当前已拨号上网的APN写入telephony.db数据库的siminfo表中。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/238311.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

WEB渗透—反序列化(十)

Web渗透—反序列化 课程学习分享&#xff08;课程非本人制作&#xff0c;仅提供学习分享&#xff09; 靶场下载地址&#xff1a;GitHub - mcc0624/php_ser_Class: php反序列化靶场课程&#xff0c;基于课程制作的靶场 课程地址&#xff1a;PHP反序列化漏洞学习_哔哩哔_…

git的基本命令操作超详细解析教程

Git基础教学 1、初始化配置2、初始化仓库3、工作区域和文件状态4、添加和提交文件5、git reset 回退版本6、git diff查看差异7、删除文件git rm8、.gitignore9、本地文件提交到远程仓库10、分支基础 Git&#xff1a;一个开源的分布式版本控制系统&#xff0c;它可以在本地和远程…

【LeetCode每日一题合集】2023.11.20-2023.11.26 (二叉树中的伪回文路径)

文章目录 53. 最大子数组和解法1——DP解法2——分治&#xff08;维护区间、类似线段树的思想&#xff09; 2216. 美化数组的最少删除数&#xff08;贪心&#xff09;2304. 网格中的最小路径代价1410. HTML 实体解析器&#xff08;模拟&#xff09;2824. 统计和小于目标的下标对…

高端网站设计公司 -蓝蓝设计数据可视化大屏服务

UI设计公司-蓝蓝设计&#xff08;北京兰亭妙微科技有限公司&#xff09;是一支由清华美院毕业的专业团队组成的设计公司。我们的设计师们在大屏科研信息软件UI设计领域拥有多年的工作经验和丰富的行业知识。我们对设计充满热爱&#xff0c;设计不仅是我们的专业和职业&#xff…

如何快速了解一家公司?

在炒股过程中&#xff0c;我们想要了解一家公司是否具有投资价值&#xff0c;需要查看和阅读很多公司的相关资料。股民们自行去查询往往会花费很多的时间精力&#xff0c;所以专业的炒股软件一般都会给股民提供这些现成的资料。 在金斗云智投APP内&#xff0c;进入到个股详情页…

2023年中国消费金融行业研究报告

第一章 行业概况 1.1 定义 中国消费金融行业&#xff0c;作为国家金融体系的重要组成部分&#xff0c;旨在为消费者提供多样化的金融产品和服务&#xff0c;以满足其消费需求。这一行业包括银行、消费金融公司、小额贷款公司等多种金融机构&#xff0c;涵盖了包括消费贷款在内…

Docker的常用基本命令(基础命令)

文章目录 1. Docker简介2. Docker环境安装Linux安装 3. 配置镜像加速4. Docker镜像常用命令列出镜像列表搜索镜像下载镜像查看镜像版本删除镜像构建镜像推送镜像 5. Docker容器常用命令新建并启动容器列出容器停止容器启动容器进入容器删除容器&#xff08;慎用&#xff09;查看…

蓝桥杯day03——二进制间距

1.题目 给定一个正整数 n&#xff0c;找到并返回 n 的二进制表示中两个 相邻 1 之间的 最长距离 。如果不存在两个相邻的 1&#xff0c;返回 0 。 如果只有 0 将两个 1 分隔开&#xff08;可能不存在 0 &#xff09;&#xff0c;则认为这两个 1 彼此 相邻 。两个 1 之间的距离…

第十节HarmonyOS 常用基础组件-Image

一、组件介绍 组件&#xff08;Component&#xff09;是界面搭建与显示的最小单位&#xff0c;HarmonyOS ArkUI声名式为开发者提供了丰富多样的UI组件&#xff0c;我们可以使用这些组件轻松的编写出更加丰富、漂亮的界面。 组件根据功能可以分为以下五大类&#xff1a;基础组件…

【Android】MotionLayout实现动画

MotionLayout不断地更新&#xff0c;文章并不适用全部最近的更新内容。 文章目录 引入 ConstraintSetTransitionManager和MotionLayout有什么区别&#xff1f; 使用ConstrainSet(属性类似于ConstrainLayout) Transition属性OnClickOnSwipeKeyFrameSetKeyPositionKeyAttribute C…

Java零基础——vue篇

1.【熟悉】Vue简介 1.1 简介 它是一个构建用户界面的框架 Vue是一个前端框架 js jq https://www.pmdaniu.com/#file UI网站 UI 一般开发者使用蓝湖 工具 看着UI图 写接口 https://lanhuapp.com/web/#/item 是一个轻量级的MVVM&#xff08;Model-View-ViewModel&#xff0…

力扣15题 三数之和 双指针算法

15. 三数之和 给你一个整数数组 nums &#xff0c;判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i ! j、i ! k 且 j ! k &#xff0c;同时还满足 nums[i] nums[j] nums[k] 0 。请 你返回所有和为 0 且不重复的三元组。 注意&#xff1a;答案中不可以包含重复的三…