Skip to content

Flutter 怎么按 IP 判断用户所在国家/地区?App 出海做差异化与合规(完整代码)

做海外市场的 App,绕不开一个问题:用户第一次打开,你连他在哪个国家都不知道。弹窗让用户自己选国家地区,很多人嫌麻烦直接退出;问系统要 GPS 定位权限,第一次就弹授权框,拒绝率能到一半以上。这两种体验都很劝退。实际上有个不打扰用户的办法——查他出口 IP 的归属地,一秒钟就能判断出大概在哪个国家,足够用来决定默认语言、显示什么币种、开哪些功能。

网上搜「flutter 判断用户国家」,出来的方案多半是往 App 里塞一个离线 IP 库,几 MB 到几十 MB 的体积,数据还得跟着发版更新。这篇换一种做法:Flutter 客户端直接调免费在线接口,不注册不要 key,一个请求拿回国家码,代码完整可跑。

一、为什么出海 App 需要按 IP 判断国家

举几个真实场景:

  • 默认语言和币种。用户在美国,商店却给他看人民币价格,第一眼就露怯。启动时知道 country_code=us,默认切英文、显示美元,用户自己改的机会都少了。
  • 合规开关。这是最硬的诉求。同一个 App 在有的国家能上「直播打赏」,在另一些国家这个功能有年龄红线或直接不允许;有的内容(新闻、短剧)有地区版权限制。国家判断错了,轻则功能开错被投诉,重则下架。合规规则最好由服务端下发,但客户端总得先有个「我现在在哪个国家」的初判。
  • 活动与运营分区。新用户礼包、拉新活动按地区配置,渠道投放也要分国家看数据,IP 国家是成本最低的分桶维度。

判断地区有两种数据源:GPS 返回的是经纬度,精度高但要授权、室内还拿不到;IP 归属地返回的是国家/城市,精度到城市级,但完全不需要授权。对「默认语言、币种、合规分区」这种国家级判断,IP 完全够用,很多大厂也是先 IP 后 GPS 的降级顺序。

二、方案设计

启动流程设计成三步:

  1. 客户端请求 GET https://ip9.com.cn/get(不传参,返回当前出口 IP 及归属地,一个请求拿全);
  2. 取出 country_code,查一张「国家 → 区域策略」映射表,决定 locale、货币、功能开关;
  3. 结果缓存到本地。下次启动先读缓存秒开,再在后台静默刷新一次,避免每次启动都等网络。

两个要点提前说。第一,免费接口有 60 次/分钟/IP 的额度,对「启动查一次」绰绰有余,但别做成每次进页面都调;缓存就是干这个的。第二,请求显式带一个浏览器样式的 User-Agent,这类免费接口普遍会拦「默认特征」的请求(有的库默认 UA 会吃到 403),加一行请求头的事,别省。

还要说清边界:IP 判断的是「出口网络在哪个国家」。用户挂着 VPN、或者走公司专线出海,查出来可能是落地国家的地址,这是所有 IP 方案的共性限制,不是接口的问题。合规的硬规则放服务端下发,IP 只做初判和默认值,后面「四」里细说。

三、Flutter 实现(Dart)

用官方 http 包,pubspec.yaml 加依赖后写一个查询函数:

dart
import 'dart:convert';
import 'package:http/http.dart' as http;

/// 一次请求拿回出口 IP 和国家/地区信息;失败返回 null,由调用方降级
class IpRegion {
  final String ip;
  final String country;      // 中国 / 美国 ...
  final String countryCode;  // cn / us / jp ...
  final String prov;         // 国内为省份,海外一般为空
  final String city;         // 城市,海外一般为空
  final String isp;          // 运营商

  IpRegion({required this.ip, required this.country, required this.countryCode,
    required this.prov, required this.city, required this.isp});

  factory IpRegion.fromJson(Map<String, dynamic> d) => IpRegion(
        ip: d['ip'] ?? '',
        country: d['country'] ?? '',
        countryCode: d['country_code'] ?? '',
        prov: d['prov'] ?? '',
        city: d['city'] ?? '',
        isp: d['isp'] ?? '',
      );
}

Future<IpRegion?> fetchIpRegion() async {
  try {
    final resp = await http
        .get(Uri.parse('https://ip9.com.cn/get'),
            headers: {'User-Agent': 'Mozilla/5.0 (Linux; Android 10) GeoApp/1.0'})
        .timeout(const Duration(seconds: 6));
    if (resp.statusCode != 200) return null;
    final body = jsonDecode(utf8.decode(resp.bodyBytes)) as Map<String, dynamic>;
    if (body['ret'] != 200) return null;      // 非 200:非法 IP 或限速等
    return IpRegion.fromJson(body['data'] as Map<String, dynamic>);
  } catch (_) {
    return null;   // 超时/断网一律降级,不让启动流程崩
  }
}

国家判断本身没有技术含量,真正的核心是把「国家码 → 业务策略」的映射集中管起来。下面是区域策略类,把语言、货币、功能开关收敛在一个地方,避免散落各处 if:

dart
/// 区域策略:把国家码映射成 App 行为。合规硬规则建议服务端下发做二次校验,
/// 这里只负责客户端默认值与初判。
class RegionPolicy {
  final String locale;     // 默认语言,如 zh_CN / en_US
  final String currency;   // 展示币种,如 CNY / USD
  final bool enableGift;   // 示例功能开关:礼物打赏
  final bool requireAgeGate; // 示例合规开关:年龄确认弹窗

  const RegionPolicy(this.locale, this.currency, this.enableGift, this.requireAgeGate);

  static const _fallback = RegionPolicy('en_US', 'USD', true, false);

  static RegionPolicy of(String countryCode) {
    switch (countryCode) {
      case 'cn':       // 中国大陆
        return const RegionPolicy('zh_CN', 'CNY', true, false);
      case 'hk':       // 港澳台单独处理,语言同为中文但政策不同
      case 'mo':
      case 'tw':
        return const RegionPolicy('zh_TW', 'TWD', false, true);
      case 'us':
      case 'ca':
        return const RegionPolicy('en_US', 'USD', true, true);
      case 'jp':
        return const RegionPolicy('ja_JP', 'JPY', false, true);
      default:         // 没覆盖到的国家用英文美元兜底
        return _fallback;
    }
  }
}

App 启动处串起来:先读缓存,再异步刷新。shared_preferences 存上次的国家码:

dart
Future<RegionPolicy> bootRegion() async {
  final prefs = await SharedPreferences.getInstance();
  final cached = prefs.getString('country_code');
  if (cached != null) return RegionPolicy.of(cached); // 秒开

  final region = await fetchIpRegion();
  final code = region?.countryCode ?? 'us';
  await prefs.setString('country_code', code);
  return RegionPolicy.of(code);
}

拿到 RegionPolicy 后,用它初始化本地化(MaterialApplocale)、价格展示的币种符号,以及各功能模块的开关。启动后可以在网络恢复或 App 回前台时静默重查一次,用户跨境旅行了也能跟上。

这套代码的关键就三点:查询失败返回 null 让业务降级、国家码映射集中在一张表里、结果落地缓存。出海 App 换国家切换策略是常态,把这三处做好,后面接多少国家都不用改结构。

四、落地注意事项

  • 缓存与刷新节奏:免费接口 60 次/分钟/IP,启动查一次 + 回前台偶尔刷新完全够用;别在 build 里调接口,也别每个页面都查。
  • 合规规则服务端兜底:IP 国家只是初判,用户可能挂代理、可能人在境外用国内卡。涉及年龄、内容区域、支付等硬规则,最终以服务端下发的配置和风控结果为准,客户端别把开关写死。
  • 海外字段为空别硬拼provcity 只有国内 IP 才有值,海外返回空字符串,UI 上直接显示国家名即可(上面 IpRegion 的字段注释已经标了)。
  • 多语言文案别漏:新增国家策略时,locale 对应的翻译资源要同时补齐,否则 MaterialApp 会回退英文。
  • 隐私合规:只传 IP 不传设备信息,接口不需要任何身份标识;如果 App 面向欧盟用户,注意在隐私政策里说明 IP 归属地用于地区适配。

总结

App 出海做「默认语言、币种、合规分区」,用 IP 归属地判断国家是最省事的一档:不用定位权限、不用内置几十 MB 的离线库,一个请求拿到国家码就能跑起来。把查询封装、策略映射、缓存三块搭好,后面接新国家只是往映射表里加一行。

查询接口用的就是 IP9 免费版:GET https://ip9.com.cn/get 不传参查当前出口 IP,?ip= 传参可查任意 IPv4/IPv6,返回国家、省市、运营商、经纬度等字段,无需注册,60 次/分钟/IP。需要区县、IP 类型(家庭宽带/企业/机房)等更细字段可以看官网的 VIP 说明:https://www.ip9.com.cn