前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >Android11 Wifi连接流程之IP地址分配

Android11 Wifi连接流程之IP地址分配

作者头像
用户7557625
发布于 2021-09-14 06:58:00
发布于 2021-09-14 06:58:00
3.1K00
代码可运行
举报
运行总次数:0
代码可运行

Android11 wifi连接流程中我们代码跟踪到了supplicant中开始associate,关联成功以后就是四次握手然后连接成功。连接成功以后还需要分配IP地址,才可以通信,这一节我们看一下IP地址的获取流程。

一、在ClientModeImpl中有一个函数startIpClient。这个函数会在俩个地方被调用,一个是连接的时候ConnectModeState,一个是连接成功以后进入ObtainingIpState。这俩个地方的区别就是isFilsConnection的不同,连接过程中isFilsConnection为true,把IPClinet先关掉。如果isFilsConnection为flase,则开始处理IP地址分配。

frameworks/opt/net/wifi/service/java/com/android/server/wifi/ClientModeImpl.java

这里我们先看是怎么进入ObtainingIpState的

SupplicantStaIfaceHal中注册了一个Supplicant的回调函数,当supplicant的状态发生改变时这里就会监听到,然后WifiMonitor就会发送statechange的广播。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
private class SupplicantVendorStaIfaceHalCallback extends ISupplicantVendorStaIfaceCallback.Stub {
    private String mIfaceName;
    private SupplicantStaIfaceHalCallback mSupplicantStaIfacecallback;

    SupplicantVendorStaIfaceHalCallback(@NonNull String ifaceName, SupplicantStaIfaceHalCallback callback) {
        mIfaceName = ifaceName;
        mSupplicantStaIfacecallback = callback;
    }

    @Override
    public void onVendorStateChanged(int newState, byte[/* 6 */] bssid, int id,
                               ArrayList<Byte> ssid, boolean filsHlpSent) {
        synchronized (mLock) {
            logCallback("onVendorStateChanged");
            SupplicantState newSupplicantState =
                SupplicantStaIfaceCallbackImpl.supplicantHidlStateToFrameworkState(newState);
            WifiSsid wifiSsid = // wifigbk++
                    WifiGbk.createWifiSsidFromByteArray(NativeUtil.byteArrayFromArrayList(ssid));
            String bssidStr = NativeUtil.macAddressFromByteArray(bssid);
            if (newSupplicantState == SupplicantState.COMPLETED) {
                mWifiMonitor.broadcastNetworkConnectionEvent(
                        mIfaceName, getCurrentNetworkId(mIfaceName), filsHlpSent, bssidStr);
            }
            mWifiMonitor.broadcastSupplicantStateChangeEvent(
                    mIfaceName, getCurrentNetworkId(mIfaceName), wifiSsid, bssidStr, newSupplicantState);
        }
    }

frameworks/opt/net/wifi/service/java/com/android/server/wifi/WifiMonitor.java

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public void broadcastNetworkConnectionEvent(String iface, int networkId, boolean filsHlpSent,
        String bssid) {
    sendMessage(iface, NETWORK_CONNECTION_EVENT, networkId, filsHlpSent ? 1 : 0, bssid);
}

此时wifi状态机还在ConnectModeState,对于NETWORK_CONNECTION_EVENT的处理结果就是跳转到ObtainingIpState

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
case WifiMonitor.NETWORK_CONNECTION_EVENT:
    if (mVerboseLoggingEnabled) log("Network connection established");
    mLastNetworkId = message.arg1;
    mSentHLPs = message.arg2 == 1;
    if (mSentHLPs) mWifiMetrics.incrementL2ConnectionThroughFilsAuthCount();
    mWifiConfigManager.clearRecentFailureReason(mLastNetworkId);
    mLastBssid = (String) message.obj;
    reasonCode = message.arg2;
    // TODO: This check should not be needed after ClientModeImpl refactor.
    // Currently, the last connected network configuration is left in
    // wpa_supplicant, this may result in wpa_supplicant initiating connection
    // to it after a config store reload. Hence the old network Id lookups may not
    // work, so disconnect the network and let network selector reselect a new
    // network.
    config = getCurrentWifiConfiguration();
    if (config != null) {
        if (mWifiConfigManager.saveAutoConnectedNewNetwork(config.networkId)) {
            Log.i(TAG, "Successfully connected to new network " + config.getPrintableSsid());
            mAutoConnectNewNetworkResultNotifier.onConnectionAttemptSuccess(config.SSID);
        }
        mWifiInfo.setBSSID(mLastBssid);
        mWifiInfo.setNetworkId(mLastNetworkId);
        mWifiInfo.setMacAddress(mWifiNative.getMacAddress(mInterfaceName));

        ScanDetailCache scanDetailCache =
                mWifiConfigManager.getScanDetailCacheForNetwork(config.networkId);
        if (scanDetailCache != null && mLastBssid != null) {
            ScanResult scanResult = scanDetailCache.getScanResult(mLastBssid);
            if (scanResult != null) {
                updateConnectedBand(scanResult.frequency, true);
            }
        }

        // We need to get the updated pseudonym from supplicant for EAP-SIM/AKA/AKA'
        if (config.enterpriseConfig != null
                && config.enterpriseConfig.isAuthenticationSimBased()) {
            mLastSubId = mWifiCarrierInfoManager.getBestMatchSubscriptionId(config);
            mLastSimBasedConnectionCarrierName =
                mWifiCarrierInfoManager.getCarrierNameforSubId(mLastSubId);
            String anonymousIdentity =
                    mWifiNative.getEapAnonymousIdentity(mInterfaceName);
            if (!TextUtils.isEmpty(anonymousIdentity)
                    && !WifiCarrierInfoManager
                    .isAnonymousAtRealmIdentity(anonymousIdentity)) {
                String decoratedPseudonym = mWifiCarrierInfoManager
                        .decoratePseudonymWith3GppRealm(config,
                                anonymousIdentity);
                if (decoratedPseudonym != null) {
                    anonymousIdentity = decoratedPseudonym;
                }
                if (mVerboseLoggingEnabled) {
                    log("EAP Pseudonym: " + anonymousIdentity);
                }
                // Save the pseudonym only if it is a real one
                config.enterpriseConfig.setAnonymousIdentity(anonymousIdentity);
            } else {
                // Clear any stored pseudonyms
                config.enterpriseConfig.setAnonymousIdentity(null);
            }
            mWifiConfigManager.addOrUpdateNetwork(config, Process.WIFI_UID);
        }
        mIpReachabilityMonitorActive = true;
        transitionTo(mObtainingIpState);
    } else {
        logw("Connected to unknown networkId " + mLastNetworkId
                + ", disconnecting...");
        sendMessage(CMD_DISCONNECT);
    }

在ObtainingIpState进入时就会开启IPClient,注意这里if (mIpClientWithPreConnection && mIpClient != null) {这个判断条件一定是不成立的,因为在连接时执行过stopIpClient。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
class ObtainingIpState extends State {
    @Override
    public void enter() {
        // Reset power save mode after association.
        // Kernel does not forward power save request to driver if power
        // save state of that interface is same as requested state in
        // cfg80211. This happens when driver’s power save state not
        // synchronized with cfg80211 power save state.
        // By resetting power save state resolves issues of cfg80211
        // ignoring enable power save request sent in ObtainingIpState.
        mWifiNative.setPowerSave(mInterfaceName, false);

        WifiConfiguration currentConfig = getCurrentWifiConfiguration();
        if (mIpClientWithPreConnection && mIpClient != null) {
            mIpClient.notifyPreconnectionComplete(mSentHLPs);
            mIpClientWithPreConnection = false;
            mSentHLPs = false;
        } else {
            startIpClient(currentConfig, false);
        }
        // Get Link layer stats so as we get fresh tx packet counters
        getWifiLinkLayerStats();
    }

二、接着我们再看startIpClient的具体内容。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
private boolean startIpClient(WifiConfiguration config, boolean isFilsConnection) {
    final boolean isUsingStaticIp =
            (config.getIpAssignment() == IpConfiguration.IpAssignment.STATIC);
    final boolean isUsingMacRandomization =
            config.macRandomizationSetting
                    == WifiConfiguration.RANDOMIZATION_PERSISTENT
                    && isConnectedMacRandomizationEnabled();
    if (isFilsConnection) {
        stopIpClient();
        if (isUsingStaticIp) {
            mWifiNative.flushAllHlp(mInterfaceName);
            return false;
        }
        setConfigurationsPriorToIpClientProvisioning(config);
        final ProvisioningConfiguration.Builder prov =
                new ProvisioningConfiguration.Builder()
                .withPreDhcpAction()
                .withPreconnection()
                .withApfCapabilities(
                mWifiNative.getApfCapabilities(mInterfaceName))
                .withLayer2Information(layer2Info);
        if (isUsingMacRandomization) {
            // Use EUI64 address generation for link-local IPv6 addresses.
            prov.withRandomMacAddress();
        }
        mIpClient.startProvisioning(prov.build());
    } else {
        sendNetworkChangeBroadcast(DetailedState.OBTAINING_IPADDR);
        clearTargetBssid("ObtainingIpAddress");
        stopDhcpSetup();
        setConfigurationsPriorToIpClientProvisioning(config);
        ScanDetailCache scanDetailCache =
                mWifiConfigManager.getScanDetailCacheForNetwork(config.networkId);
        ScanResult scanResult = null;
        if (mLastBssid != null) {
            if (scanDetailCache != null) {
                scanResult = scanDetailCache.getScanResult(mLastBssid);
            }
            if (scanResult == null) {
                ScanRequestProxy scanRequestProxy = mWifiInjector.getScanRequestProxy();
                List<ScanResult> scanResults = scanRequestProxy.getScanResults();
                for (ScanResult result : scanResults) {
                    if (result.SSID.equals(WifiInfo.removeDoubleQuotes(config.SSID))
                            && result.BSSID.equals(mLastBssid)) {
                        scanResult = result;
                        break;
                    }
                }
            }
        }
        final ProvisioningConfiguration.Builder prov;
        ProvisioningConfiguration.ScanResultInfo scanResultInfo = null;
        if (scanResult != null) {
            final List<ScanResultInfo.InformationElement> ies =
                    new ArrayList<ScanResultInfo.InformationElement>();
            for (ScanResult.InformationElement ie : scanResult.getInformationElements()) {
                ScanResultInfo.InformationElement scanResultInfoIe =
                        new ScanResultInfo.InformationElement(ie.getId(), ie.getBytes());
                ies.add(scanResultInfoIe);
            }
            scanResultInfo = new ProvisioningConfiguration.ScanResultInfo(scanResult.SSID,
                    scanResult.BSSID, ies);
        }

        if (!isUsingStaticIp) {
            prov = new ProvisioningConfiguration.Builder()
                .withPreDhcpAction()
                .withApfCapabilities(mWifiNative.getApfCapabilities(mInterfaceName))
                .withNetwork(getCurrentNetwork())
                .withDisplayName(config.SSID)
                .withScanResultInfo(scanResultInfo)
                .withLayer2Information(layer2Info);
        } else {
            StaticIpConfiguration staticIpConfig = config.getStaticIpConfiguration();
            prov = new ProvisioningConfiguration.Builder()
                    .withStaticConfiguration(staticIpConfig)
                    .withApfCapabilities(mWifiNative.getApfCapabilities(mInterfaceName))
                    .withNetwork(getCurrentNetwork())
                    .withDisplayName(config.SSID)
                    .withLayer2Information(layer2Info);
        }
        if (isUsingMacRandomization) {
            // Use EUI64 address generation for link-local IPv6 addresses.
            prov.withRandomMacAddress();
        }
        mIpClient.startProvisioning(prov.build());
    }

    return true;
}

三、IpClientManager通过aidl与IPClinet模块通信。

frameworks/base/services/net/java/android/net/ip/IpClientManager.java

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public class IpClientManager {
    @NonNull private final IIpClient mIpClient;
    @NonNull private final String mTag;

    public IpClientManager(@NonNull IIpClient ipClient, @NonNull String tag) {
        mIpClient = ipClient;
        mTag = tag;
    }

IPClinet会发送CMD_START信息,然后会进入StartedState。

frameworks/base/packages/NetworkStack/src/android/net/ip/IpClient.java

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public void startProvisioning(ProvisioningConfiguration req) {
    if (!req.isValid()) {
        doImmediateProvisioningFailure(IpManagerEvent.ERROR_INVALID_PROVISIONING);
        return;
    }

    mInterfaceParams = mDependencies.getInterfaceParams(mInterfaceName);
    if (mInterfaceParams == null) {
        logError("Failed to find InterfaceParams for " + mInterfaceName);
        doImmediateProvisioningFailure(IpManagerEvent.ERROR_INTERFACE_NOT_FOUND);
        return;
    }

    mCallback.setNeighborDiscoveryOffload(true);
    sendMessage(CMD_START, new android.net.shared.ProvisioningConfiguration(req));
}

最后进入了RunningState。在这里会开始Ipv6和Ipv4

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
class RunningState extends State {
    private ConnectivityPacketTracker mPacketTracker;
    private boolean mDhcpActionInFlight;

    @Override
    public void enter() {
        ApfFilter.ApfConfiguration apfConfig = new ApfFilter.ApfConfiguration();
        apfConfig.apfCapabilities = mConfiguration.mApfCapabilities;
        apfConfig.multicastFilter = mMulticastFiltering;
        // Get the Configuration for ApfFilter from Context
        apfConfig.ieee802_3Filter = ApfCapabilities.getApfDrop8023Frames();
        apfConfig.ethTypeBlackList = ApfCapabilities.getApfEtherTypeBlackList();
        mApfFilter = ApfFilter.maybeCreate(mContext, apfConfig, mInterfaceParams, mCallback);
        // TODO: investigate the effects of any multicast filtering racing/interfering with the
        // rest of this IP configuration startup.
        if (mApfFilter == null) {
            mCallback.setFallbackMulticastFilter(mMulticastFiltering);
        }

        mPacketTracker = createPacketTracker();
        if (mPacketTracker != null) mPacketTracker.start(mConfiguration.mDisplayName);

        if (mConfiguration.mEnableIPv6 && !startIPv6()) {
            doImmediateProvisioningFailure(IpManagerEvent.ERROR_STARTING_IPV6);
            enqueueJumpToStoppingState();
            return;
        }

        if (mConfiguration.mEnableIPv4 && !startIPv4()) {
            doImmediateProvisioningFailure(IpManagerEvent.ERROR_STARTING_IPV4);
            enqueueJumpToStoppingState();
            return;
        }

        final InitialConfiguration config = mConfiguration.mInitialConfig;
        if ((config != null) && !applyInitialConfig(config)) {
            // TODO introduce a new IpManagerEvent constant to distinguish this error case.
            doImmediateProvisioningFailure(IpManagerEvent.ERROR_INVALID_PROVISIONING);
            enqueueJumpToStoppingState();
            return;
        }

        if (mConfiguration.mUsingIpReachabilityMonitor && !startIpReachabilityMonitor()) {
            doImmediateProvisioningFailure(
                    IpManagerEvent.ERROR_STARTING_IPREACHABILITYMONITOR);
            enqueueJumpToStoppingState();
            return;
        }
    }

这里会发送广播CMD_START_DHCP给DHCPClinet。到了这一步就和Android11 DHCP流程接上了。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
    private boolean startIPv4() {
        // If we have a StaticIpConfiguration attempt to apply it and
        // handle the result accordingly.
        if (mConfiguration.mStaticIpConfig != null) {
            if (mInterfaceCtrl.setIPv4Address(mConfiguration.mStaticIpConfig.getIpAddress())) {
                handleIPv4Success(new DhcpResults(mConfiguration.mStaticIpConfig));
            } else {
                return false;
            }
        } else {
            // Start DHCPv4.
            mDhcpClient = DhcpClient.makeDhcpClient(mContext, IpClient.this, mInterfaceParams);
            mDhcpClient.registerForPreDhcpNotification();
            if (mConfiguration.mRapidCommit || mConfiguration.mDiscoverSent)
                mDhcpClient.sendMessage(DhcpClient.CMD_START_DHCP_RAPID_COMMIT,
                    (mConfiguration.mRapidCommit ? 1: 0),
                    (mConfiguration.mDiscoverSent ? 1: 0));
            else
                mDhcpClient.sendMessage(DhcpClient.CMD_START_DHCP);
        }

        return true;
    }
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021/09/10 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
ggbrick | 小众到惊艳的可视化工具...
以前介绍的工具大部分都受众较广,且涉及较多的统计变换分析,今天就给大家介绍一个小众的、但是在商务插图里常见的一个数据可视化工具包- 「ggbrick」
DataCharm
2024/06/04
1410
ggbrick | 小众到惊艳的可视化工具...
太好用!模型结果也可以可视化表示啦...
有学员向我提问,咨询有没有关于模型可视化的一些工具推荐。特意找了一下资料,这就给大家介绍一个非常好用的Python可视化工具-scikit-plot,专门用于模型结果的可视化展示,功能比较简单易懂。
DataCharm
2023/11/15
9510
太好用!模型结果也可以可视化表示啦...
卷死!这个雷达数据可视化工具真的很好用,都来学...
今天我们课程学员的小伙伴向我咨询关于天气雷达图的绘制,最近学习的Py-ART 就可以排上用场了,下面就简单的给大家介绍一下啦~~
DataCharm
2023/11/22
1.7K0
卷死!这个雷达数据可视化工具真的很好用,都来学...
比Python绘制散点密度图还方便?!怎么选?当然全都要...
昨天给大家推荐了Python语言绘制散点密度图的可视化工具-mpl-scatter-density,很多同学都表示使用起来非常方便。但是也有同学一直使用R语言进行可视化绘图,所以今天这篇推文就给大家推荐R语言快速绘制散点密度图的方法。
DataCharm
2023/11/22
3870
比Python绘制散点密度图还方便?!怎么选?当然全都要...
Python也可以快速绘制森林图啦!赶紧学..
最近在修订《科研论文配图绘制指南-基于Python》一书的部分章节时,发现在介绍森林图(forest plot) 的绘制方法较为繁琐,决定重新进行修订,当然,修订后的代码和介绍会发布到我们的学习圈子中。今天这篇推文就介绍一下Python绘制森林图的一个超简单工具包-MyForestPlot。
DataCharm
2023/11/16
1.5K0
Python也可以快速绘制森林图啦!赶紧学..
ggmagnify | 这种局部地图绘制不要太简单...
今天工作了,就赶紧给大家推荐一个好用的具体子图显示绘制工具-「ggmagnify」
DataCharm
2024/05/11
3640
ggmagnify | 这种局部地图绘制不要太简单...
ggPlantmap | 动植物图片轻松转成ggplot2对象,数值映射真的很简单...
今天给大家介绍的可视化工具是-「ggPlantmap」,一个可以一键将图片对象转换成ggplot2绘图对象的好用工具,让数值映射变得超简单~~
DataCharm
2024/05/21
2590
ggPlantmap | 动植物图片轻松转成ggplot2对象,数值映射真的很简单...
局部地图绘制真的太简单,推荐学习这个工具...
ggmagnify 是一个R语言中用于绘制放大镜效果的数据可视化工具,它基于ggplot2包,可以用于放大图表中的特定区域,并在放大的区域周围添加一个放大镜效果的框,以便更清晰地展示细节,特别是在数据密集的图表中。
DataCharm
2024/07/30
1640
局部地图绘制真的太简单,推荐学习这个工具...
ggcharts| 一键绘制出版级商务图表,真的很赞...
这么说吧,机会常见的统计图表都可以一键绘制,而且绘制的结果直接可以达到出版级别的那种,特别适合科研和商务绘图爱好者。
DataCharm
2024/06/18
1510
ggcharts| 一键绘制出版级商务图表,真的很赞...
geofacet!另类网格地图绘制,商务地图就靠它了...
在对我们的(R语言可视化课程)的学员进行统计想要绘制的图表类型时,也是我们接下里要免费新增的内容。很多同学都提到了下面这个地图类型的绘制方法:
DataCharm
2024/01/05
3890
geofacet!另类网格地图绘制,商务地图就靠它了...
rempsyc!一键美化学术论文中的表格和图形,真的太适合科研党了...
今天在查阅资料的时候,偶尔发现一个超好用的科研工具-「rempsyc」,其提供多个函数可以将学术论文编写过程中的统计图表一键美化、常见统计图形绘制等,简直就是科研党的首选工具。
DataCharm
2024/04/30
7640
rempsyc!一键美化学术论文中的表格和图形,真的太适合科研党了...
ggfittext | 这样绘制文本不要太简单了...
其实这个问题在需要有文本标注的图形中经常遇到,在文本数量较多且图形布局较为拥挤时,大部分制作者选择使用图片处理工具如AI等,进行单独的文本添加。
DataCharm
2024/05/11
1810
ggfittext | 这样绘制文本不要太简单了...
数据相差太大,无法突出重点数据!?这样干就行...
其实这个问题,在可视化绘制需求中经常会遇到,按要求绘制出图形结果后,又因为每组数据值相差太大,到值绘制的图形结果非常难看,但想要解决这个问题,只需要将刻度轴 进行截断处理一下就可以了。下面我就给大家介绍绘制截断刻度轴的两种方法(仅限Python语言)
DataCharm
2023/11/24
3900
数据相差太大,无法突出重点数据!?这样干就行...
Antarctic-Plots!不用ArcGIS,我照样可以画出惊艳的地图...
最近给学员们免费新增空间数据可视化学习资源推荐~~内容时,发现了一个非常好用但非常小众的强悍空间可视化工具-「Antarctic-Plots」,今天这篇推文就简单介绍一下该工具。更多关于该工具的语法和案例,可以参与我们的课程哈,还有交流答疑群呢~~
DataCharm
2023/12/15
2490
Antarctic-Plots!不用ArcGIS,我照样可以画出惊艳的地图...
这样截断刻度轴学术图表怎么绘制?一行代码搞定...
显然,这种图形最大的一个特点就是刻度轴进行了截断处理,下面我们就详细介绍一下截断刻度轴的含义和其Python绘制方法.
DataCharm
2024/04/25
4490
这样截断刻度轴学术图表怎么绘制?一行代码搞定...
炫酷!这样的决策树图一键轻松绘制,这个工具真的强...
很多同学最近在咨询有没有那种看起来比较炫酷和决策树图的可视化绘制方法? 今天就给各位小伙伴介绍一个专门用于绘制炫酷「决策树(Decision Tree )图」的可视化工具-「treeheatr」
DataCharm
2024/04/25
4350
炫酷!这样的决策树图一键轻松绘制,这个工具真的强...
Earthpy | 这样超赞的艺术地图也能轻松绘制...
最近在整理Python数据可视化课程的拓展内容时,发现了一个处理空间数据的超赞工具-「earthpy」,也解决了一个绘制艺术地图的问题,下面就给大家详细介绍一下这个工具~~
DataCharm
2024/05/11
2880
Earthpy | 这样超赞的艺术地图也能轻松绘制...
Iris!真的!几乎所有常见的地图它都能绘制...
最近在新增Python数据可视化课程的拓展内容时,发现了一个处理空间数据的超赞工具-「Iris」,下面就给大家详细介绍一下这个工具~~
DataCharm
2024/05/25
1960
Iris!真的!几乎所有常见的地图它都能绘制...
不是!这才是对角矩阵系列统计图的正确打开方式啊~~
之前介绍过R语言绘制对角矩阵系列统计图表的文章不是?!这种图一行代码就搞定了,超简单...。今天继续给大家推荐一个个人感觉更好用的对角矩阵图表绘制工具-「corrmorant」。
DataCharm
2024/04/11
3310
不是!这才是对角矩阵系列统计图的正确打开方式啊~~
这种两个Colorbar的图形怎么绘制?这样做真的超简单...
其实,这个技巧在我们课程新增的案例里就有类似的内容,今天就Python语言中Matplotlib工具,简单给大家介绍下,同时绘制两个colorbar的绘图技巧
DataCharm
2024/05/11
3950
这种两个Colorbar的图形怎么绘制?这样做真的超简单...
推荐阅读
相关推荐
ggbrick | 小众到惊艳的可视化工具...
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档