Skip to content

iOS 获取 IP 归属地怎么做?Swift 直调免费接口,不申请定位权限也能拿到用户城市(附完整代码)

做个本地生活类 App,首页要显示「当前城市」,做天气、同城内容、登录后的城市安全提示,都得先知道用户在哪座城市。为这点需求去申请系统定位权限,很不划算:iOS 弹窗很吓人,用户拒绝率不低,App Store 审核时还要解释位置用途。后来换了个思路——用 IP 归属地拿城市级位置。一搜「ios 获取ip归属地」,前排全是 2016-2019 年的老代码,教的还是 getifaddrs 拿本机地址,方向就错了。这篇把 2026 年还能直接用的做法写清楚:Swift 客户端直调免费接口,一次请求同时拿到出口 IP 和归属地,不用注册、不用 key、不用申请任何权限。

一、先分清:iOS 上你能拿到的到底是哪个 IP

这是整件事最容易踩的坑。手机上不管连 WiFi 还是走蜂窝流量,应用层能读到的都是内网地址:WiFi 下是路由器分的 192.168.x.x,蜂窝下是运营商内网(10.x、100.64.x 这类 CGNAT 地址)。老教程里的 getifaddrs() 遍历网卡,拿到的就是这些东西。拿内网地址去查归属地,返回要么为空要么是错的——因为归属地是出口公网 IP 的属性,内网地址根本不在库。

方向只有一条:让请求真正走到外网,由服务器告诉你「你是从哪个公网 IP 出来的」。传统写法要两步:先调一个「查我的 IP」的接口拿到出口 IP,再拿这个 IP 去查归属地,两次网络往返、解析两份 JSON。

IP9 的免费接口把两步合成了一步:GET https://ip9.com.cn/get 不传任何参数,直接返回当前请求方的公网 IP 和它的归属地,一个请求全齐,客户端这边省事很多。接口返回的字段包括 ipcountryprov(省份)、cityisp(运营商)、lng/lat(城市中心经纬度)等,够展示用了。

另一个要提前说的点:同一个手机,WiFi 和流量的出口不一样——WiFi 走宽带出口,流量走基站出口,所以两种网络下查出来的归属地可能不同,这是网络结构决定的,不是接口不准。

二、为什么「用 IP 拿城市」比申请定位权限更合适

不是所有场景都值得动定位权限。做个判断:

  • 需要米级精度(导航、打车、外卖配送)→ 必须定位;
  • 只需要城市级信息(天气、首页城市、同城内容、登录地提示、风控参考)→ IP 归属地足够,而且城市级通常对应到「市」,接口返回的经纬度是城市中心点,不是你的街道位置,本来就不该拿它当精确坐标用。

iOS 侧用 IP 有三个实打实的好处。一是没有权限弹窗,用户无感知,不会因为「为什么这破 App 要我位置」被拒绝;二是隐私合规简单,位置是敏感个人信息,而城市级网络归属信息敏感度低得多,隐私政策里交代一句即可;三是省掉定位 SDK 的接入和 App Store 审核时对位置用途的说明。缺点也要认:精度是城市级、共享网络(公司 WiFi、商场 WiFi)会显示宽带所在地、挂代理会显示落地地区——但做城市展示完全够用。

三、Swift 实现

思路:App 启动或进相关页面时,用 URLSession 请求 https://ip9.com.cn/get,把返回 JSON 解析成模型,缓存起来供页面使用。全程只用系统框架,不引第三方库。iOS 的 ATS 默认允许 HTTPS,ip9.com.cn 是 HTTPS,所以 Info.plist 里不用加任何例外配置。

swift
import Foundation

// 接口返回结构(免费版字段,按需取用)
struct IpQueryResult: Codable {
    let ret: Int
    let data: IpData?
    let qt: Double?

    struct IpData: Codable {
        let ip: String
        let country: String        // 中国 / 泰国 ...
        let countryCode: String    // cn / th / us ...
        let prov: String?          // 省份,海外 IP 一般为空
        let city: String?          // 城市
        let isp: String?           // 运营商
        let lng: String?           // 城市中心经度
        let lat: String?           // 城市中心纬度

        enum CodingKeys: String, CodingKey {
            case ip, country, isp, lng, lat
            case countryCode = "country_code"
            case prov, city
        }
    }
}

enum IpQueryError: Error {
    case badRequest      // ret != 200(非法 IP / 服务异常)
    case rateLimited     // ret == 429,被限速
    case network
}

// 查询当前网络出口 IP 的归属地
func fetchMyLocation(completion: @escaping (Result<IpQueryResult.IpData, IpQueryError>) -> Void) {
    guard let url = URL(string: "https://ip9.com.cn/get") else {
        completion(.failure(.network))
        return
    }
    var request = URLRequest(url: url)
    request.timeoutInterval = 8
    // 显式声明 UA,避免默认请求特征被风控误伤
    request.setValue("IP9Demo/1.0 (iPhone; iOS 17.0)", forHTTPHeaderField: "User-Agent")

    URLSession.shared.dataTask(with: request) { data, response, error in
        if error != nil {
            completion(.failure(.network))
            return
        }
        guard let data = data,
              let result = try? JSONDecoder().decode(IpQueryResult.self, from: data),
              result.ret == 200,
              let info = result.data else {
            // 拿不到 data 时,尝试从 ret 区分原因
            if let data = data,
               let raw = try? JSONDecoder().decode(IpQueryResult.self, from: data),
               raw.ret == 429 {
                completion(.failure(.rateLimited))
            } else {
                completion(.failure(.badRequest))
            }
            return
        }
        completion(.success(info))
    }.resume()
}

// 展示文案:国内显示「广东 广州 · 中国电信」,海外只显示国家
func locationText(_ info: IpQueryResult.IpData) -> String {
    if info.country == "中国" {
        let parts = [info.prov, info.city].compactMap { $0 }
        return parts.joined(separator: " ") + (info.isp.map { " · \($0)" } ?? "")
    }
    return info.country
}

使用方(比如首页 viewDidLoad 里)这样调:

swift
fetchMyLocation { result in
    DispatchQueue.main.async {
        switch result {
        case .success(let info):
            cityLabel.text = locationText(info)
            // 顺手记住出口 IP,后面做缓存 key
            lastIp = info.ip
        case .failure(.rateLimited):
            cityLabel.text = "定位繁忙,稍后再试"
        default:
            cityLabel.text = nil // 查询失败静默处理,别挡主流程
        }
    }
}

想查指定 IP 也简单,URL 拼参数:https://ip9.com.cn/get?ip=8.8.8.8,IPv4 和 IPv6 都支持,返回结构一样。

四、落地注意事项

几个容易翻车的地方,提前说:

  1. 缓存一定要做。免费接口额度是 60 次/分钟/IP,对「首页显示城市」这种低频场景绰绰有余,但你要是每个页面、每次进首页都打一次,用户刷两下就触顶了。客户端按出口 IP 做缓存:同一个出口 IP 在 10 分钟内查过的结果直接用,别重复请求。用户换网络(WiFi 切流量)后出口 IP 变了,自然会重新查,缓存逻辑天然正确。
  2. 失败要降级,别阻断功能。查询失败就显示空或「未知」,城市加载不出来不能影响页面主流程。限速(429)时更是如此,退避一会儿再试。
  3. 别把内网 IP 拿去查。如果你在 App 里需要展示「本机 IP」,那是另一回事;归属地查询只认公网出口 IP,代码里别出现 getifaddrs 那套,方向错了怎么查都是错的。
  4. 合规上留个心眼。只取城市级信息用于展示,别拿去做用户画像级的东西;如果要把 IP 和查询结果存库(比如登录审计),隐私政策里写清楚「为保障账号安全/提供服务,我们可能记录 IP 地址并查询网络归属地」,一句话的事。
  5. 场景纯度。如果这个 App 本身完全没申请定位权限,就别在工程里混入任何 CoreLocation 代码,审核和隐私标签都干净。

总结

iOS 上要「知道用户在哪个城市」,先想清楚是不是真的需要定位权限:城市级展示用 IP 归属地就够,用户无感、合规简单、实现还少。完整链路就是 Swift 里一次 URLSession 请求 https://ip9.com.cn/get(无参即查当前网络),解析出省市运营商字段,按出口 IP 缓存、失败降级,收工。免费版无需注册,60 次/分钟/IP,客户端低频场景够用;查询入口和字段说明见官网 https://www.ip9.com.cn,接口地址 https://ip9.com.cn/get。