Skip to content

鸿蒙 HarmonyOS 应用怎么获取 IP 归属地?ArkTS 直调接口,不申请定位权限(完整代码) ​

做鸿蒙应用的朋友大概率搜过这个问题:「怎么拿到用户所在的城市?」

第一反应是定位权限。但真去申请 ohos.permission.LOCATION,你会发现代价不小:应用市场审核要写清楚用途、用户第一次打开就弹授权框、拒绝授权的用户直接流失一部分,而你要的可能只是首页显示一个「南京」、给资讯流排个本地频道。

如果需求就是这个量级,根本不用动定位。IP 归属地就够了——不弹权限框、不涉及设备定位开关,拿到的粒度是省市 + 运营商,正好对上「默认城市」这种场景。这篇文章讲鸿蒙(HarmonyOS NEXT / API 12)里怎么用 ArkTS 把这件事做干净:网络请求、类型定义、缓存策略、错误兜底,以及一个很多人踩过的坑——你查到的到底是谁的 IP。

一、先纠正两个常见的误解 ​

误解一:鸿蒙有 API 能直接读本机公网 IP。 没有。@kit.NetworkKit 里的连接管理能力给的是网络状态、链路类型、网卡地址这类信息,不是出口公网 IP;应用侧也拿不到运营商分配的出口地址。想得到「用户当前在哪个城市」,正确姿势是请求一个「查询请求方 IP」的接口——请求从用户设备发出去,服务端看到的就是这个设备的出口 IP,返回的归属地自然就是用户所在地。IP9 免费版不带参数的 https://ip9.com.cn/get 就是这个语义。

误解二:前端查到的属地可以拿来当风控证据。 不行。客户端发出去的请求,参数、请求头、甚至代理设置都在用户手里,能改能伪造。属地展示、默认城市、语言排序这类体验型逻辑可以放前端;涉及封号、限制、资金的操作,必须在服务端用服务端看到的 IP 重新查一遍。这两条线一定要分开,不然后面做风控时会把前端的值当可信输入,留下一个很大的口子。

顺带把第三个坑提前说了:「显示内容发布者的 IP 属地」不能在前端查。发布者的属地是「他发布那一刻的 IP」,必须在他发布时由服务端记录并存库;前端查接口拿到的是当前阅读者的属地。

二、准备:一个权限 + 一个理解 ​

权限只需要一个,写在 entry/src/main/module.json5 里:

json5
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "$string:reason_network",
        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
      }
    ]
  }
}

ohos.permission.INTERNET 是普通权限(normal 级别),声明即生效,不需要运行时弹框。注意一个开发期的干扰项:在 DevEco 的 Previewer 里即使不声明权限也能发出请求,只是有告警;但真机和模拟器上不声明就直接失败,所以别依赖预览器的表现判断权限配好了没有。

关于接口本身,免费版字段够用:country、prov、city、city_code、isp、lng/lat(城市中心经纬度)、big_area(华东/华南这类大区)、long_ip、post_code、area_code。区县 area 在免费版是空串,要区县级数据得用 VIP 版。返回结构长这样:

json
{
  "ret": 200,
  "data": {
    "ip": "114.114.114.114", "country": "中国", "country_code": "cn",
    "prov": "江苏", "city": "南京", "city_code": "nanjing",
    "isp": "114DNS", "lng": "118.77", "lat": "32.04",
    "big_area": "华东", "post_code": "210000", "area_code": "025"
  },
  "qt": 0.001
}

三、ArkTS 实现 ​

3.1 定义类型和属地服务 ​

ArkTS 是强类型的,JSON.parse 出来的对象必须断言成明确的 interface,不能像 JS 那样随手点属性。把接口返回结构先声明好,后面所有取值都有类型保障。

ts
// entry/src/main/ets/service/GeoService.ets
import { http } from '@kit.NetworkKit';
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

export interface Ip9Location {
  ip: string;
  country: string;
  country_code: string;
  prov: string;
  city: string;
  city_code: string;
  isp: string;
  lng: string;
  lat: string;
  big_area: string;
}

interface Ip9Response {
  ret: number;
  data: Ip9Location;
  qt: number;
}

export class GeoService {
  // 不传 ip 参数:返回的就是「发起请求这台设备」的出口 IP 归属地
  private static readonly API: string = 'https://ip9.com.cn/get';
  private static readonly STORE: string = 'ip_geo_store';
  private static readonly KEY: string = 'my_location';
  private static readonly TTL: number = 6 * 60 * 60 * 1000; // 属地缓存 6 小时

  /**
   * 取当前设备的 IP 属地。
   * 三级缓存:内存 → 首选项 → 网络请求;任何失败都返回 null,不抛异常给页面。
   */
  static async getMyLocation(context: common.Context): Promise<Ip9Location | null> {
    const cached = GeoService.readCache(context);
    if (cached !== null) {
      return cached;
    }

    let geo = await GeoService.requestOnce();
    if (geo === null) {
      geo = await GeoService.requestOnce();   // 只重试一次,超时定在 3 秒
    }
    if (geo !== null && geo.city !== '') {
      GeoService.writeCache(context, geo);
      return geo;
    }
    return null;
  }

  private static async requestOnce(): Promise<Ip9Location | null> {
    const httpRequest = http.createHttp();
    try {
      const resp: http.HttpResponse = await httpRequest.request(GeoService.API, {
        method: http.RequestMethod.GET,
        header: { 'Accept': 'application/json' },
        expectDataType: http.HttpDataType.STRING, // 明确要字符串,省掉 ArrayBuffer 转换
        usingCache: false,
        connectTimeout: 3000,
        readTimeout: 3000
      });

      if (resp.responseCode !== 200) {
        hilog.warn(0x0000, 'GeoService', 'upstream http %{public}d', resp.responseCode);
        return null;
      }
      if (resp.resultType !== http.HttpDataType.STRING || typeof resp.result !== 'string') {
        hilog.warn(0x0000, 'GeoService', 'unexpected result type');
        return null;
      }

      const parsed: Ip9Response = JSON.parse(resp.result) as Ip9Response;
      if (parsed.ret !== 200 || parsed.data === undefined) {
        hilog.warn(0x0000, 'GeoService', 'ret=%{public}d', parsed.ret);
        return null;
      }
      return parsed.data;
    } catch (err) {
      const e = err as BusinessError;
      hilog.error(0x0000, 'GeoService', 'request failed: code=%{public}d msg=%{public}s',
        e.code, JSON.stringify(e.message));
      return null;
    } finally {
      httpRequest.destroy(); // 必须销毁,否则连接和回调不会释放
    }
  }

  /** 首选项缓存:带上时间戳,过期就当没有 */
  private static readCache(context: common.Context): Ip9Location | null {
    try {
      const store = preferences.getPreferencesSync(context, { name: GeoService.STORE });
      const raw = store.getSync(GeoService.KEY, '') as string;
      if (raw === '') {
        return null;
      }
      const item: Record<string, Object> = JSON.parse(raw) as Record<string, Object>;
      const savedAt = item.savedAt as number;
      if (Date.now() - savedAt > GeoService.TTL) {
        return null;
      }
      const geo = item.geo as Ip9Location;
      return geo.city === '' ? null : geo;
    } catch (err) {
      hilog.warn(0x0000, 'GeoService', 'read cache failed');
      return null;
    }
  }

  private static writeCache(context: common.Context, geo: Ip9Location): void {
    try {
      const store = preferences.getPreferencesSync(context, { name: GeoService.STORE });
      store.putSync(GeoService.KEY, JSON.stringify({ savedAt: Date.now(), geo: geo }));
      store.flush(); // 异步落盘,不阻塞 UI
    } catch (err) {
      hilog.warn(0x0000, 'GeoService', 'write cache failed');
    }
  }
}

这里的取舍值得说一下:

为什么用 expectDataType: http.HttpDataType.STRING。 默认返回是 ArrayBuffer,还要自己解码成字符串;直接声明要字符串,resp.result 拿来就能 JSON.parse,少一层转换和一处解码出错的可能。

为什么缓存 6 小时。 手机在 WiFi 和 5G 之间切换、跨城出差,出口属地都会变,但一天变好几次属于少数。真的换了城市重新打开 App,最多滞后 6 小时。如果你的业务对「当前城市」敏感(比如显示本地门店库存),把 TTL 降到 30 分钟,同时在页面下拉刷新时强制刷新一次。

为什么失败要返回 null 而不是 throw。 页面渲染不该被网络问题打断。属地拿不到就显示「属地未知」或者干脆不显示这一行,剩下内容照常渲染。

3.2 页面里用起来 ​

ts
// entry/src/main/ets/pages/CommunityPage.ets
import { common } from '@kit.AbilityKit';
import { GeoService, Ip9Location } from '../service/GeoService';

@Entry
@Component
struct CommunityPage {
  @State locationText: string = '正在识别归属地…';
  @State detailText: string = '';
  @State cityCode: string = '';

  aboutToAppear(): void {
    this.loadLocation();
  }

  async loadLocation(): Promise<void> {
    const context = getContext(this) as common.Context;
    const geo: Ip9Location | null = await GeoService.getMyLocation(context);
    if (geo === null || geo.city === '') {
      this.locationText = '属地未知';
      this.detailText = '';
      return;
    }
    this.locationText = `${geo.prov} · ${geo.city}`;
    this.detailText = `${geo.isp} · ${geo.country}`;
    this.cityCode = geo.city_code; // 后续拿它去请求本地频道数据
  }

  build() {
    Column({ space: 8 }) {
      Row({ space: 6 }) {
        Text('IP 属地').fontSize(12).fontColor('#999999')
        Text(this.locationText).fontSize(12).fontColor('#999999')
      }
      Text(this.detailText).fontSize(11).fontColor('#bbbbbb')

      if (this.cityCode !== '') {
        Text('已为你切换到本地频道').fontSize(14).fontColor('#1a1a1a')
      } else {
        Text('暂时无法识别所在城市,已按默认频道展示').fontSize(12).fontColor('#999999')
      }
    }
    .alignItems(HorizontalAlign.Start)
    .padding(16)
    .width('100%')
  }
}

页面上没什么魔法,关键是 loadLocation() 里的顺序:先拿属地,再决定展示哪个频道,而不是先渲染默认频道再跳一次(会闪一下,体验很差)。另外注意 city_code 这种稳定标识比 city 中文名更适合做后续请求参数——地级市的中文名在不同数据源偶有差异,city_code 不会。

四、落地时要注意的几件事 ​

真机、模拟器、预览器的结果不一样。 模拟器和 Previewer 的网络请求是从你的开发机发出去的,查出来的是开发机所在城市的归属地,不是你手机所在地。联调阶段看到结果「不对」,先确认跑在哪个环境里。

IPv6 网络下同样能查。 现在不少移动网络和家宽是 IPv6 优先,出口可能是一个 240e: 开头的地址。这个接口 IPv4/IPv6 都支持,返回字段结构一致,不用做分支处理。

粒度只到市级,别指望街道。 免费版给的是省市、运营商和城市中心经纬度;区县字段 area 在免费版是空串,需要区县级数据(比如判断用户是否在某市辖区做本地活动)要上 VIP 版,那个版本还额外提供 ip_type(ISP 家庭 / BUS 企业 / IDC 机房)和 ip_asn(AS 号),判断「是不是机房出口」比看 ISP 文本靠谱得多。

归属地可能和用户实际位置不一致。 用户连的是公司专线、校园网、VPN,出口城市和人在的城市可以差出几百公里;企业 VPN 出口显示成总部城市是常态。所以属地只适合做「默认值」,一定要给用户一个手动切换城市的入口,并且记住用户的手动选择——手动选的优先级永远高于自动识别的。

展示上要克制。 「IP 属地」这四个字现在用户已经普遍熟悉(社交平台都标了),但把它放在显著位置并且和内容一起展示时,要有必要的说明文案;如果用在评论区、匿名内容这类场景,请按平台规则与《个人信息保护法》的最小必要原则来,只展示到省市即可,别把 isp、经纬度、city_code 也一起漏出去——那些字段对用户没有价值,却会增加被反查的风险。

接口要能扛住失败。 免费版是 60 次/分钟的单机限额,正常情况下一个 App 用户一天也就查一两次(有缓存的情况下更少)。但要注意别在列表项里逐个请求——首页 20 条卡片各查一次,就是 20 次请求。正确做法是全局查一次,把结果放进 AppStorage 或者 @StorageLink 里共享。

总结 ​

鸿蒙里做 IP 属地展示,核心就三件事:声明 ohos.permission.INTERNET、用 @kit.NetworkKit 发一个不带参数的 GET 请求、解析成强类型 interface 后放进缓存。整条链路不涉及定位权限,用户侧零打扰,代码量也就一个 service 文件。

再强调一遍边界:前端直连得到的是「这台设备」的属地,适合展示与默认值;风控和内容归属必须服务端处理。 这个区分做好了,后面加风控需求时不会返工。

接口用 IP9 免费版即可:https://ip9.com.cn/get(不带参数查当前请求方 IP 归属地,?ip=<IP> 查指定 IP,IPv4/IPv6 都支持),免注册、无需鉴权、60 次/分钟,返回国家、省市、邮编、区号、运营商、城市中心经纬度、long_ip、big_area 等字段。需要区县、IP 类型(ip_type)和 AS 号时上 VIP 版。官网:https://www.ip9.com.cn