Appearance
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 的降级顺序。
二、方案设计
启动流程设计成三步:
- 客户端请求
GET https://ip9.com.cn/get(不传参,返回当前出口 IP 及归属地,一个请求拿全); - 取出
country_code,查一张「国家 → 区域策略」映射表,决定 locale、货币、功能开关; - 结果缓存到本地。下次启动先读缓存秒开,再在后台静默刷新一次,避免每次启动都等网络。
两个要点提前说。第一,免费接口有 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 后,用它初始化本地化(MaterialApp 的 locale)、价格展示的币种符号,以及各功能模块的开关。启动后可以在网络恢复或 App 回前台时静默重查一次,用户跨境旅行了也能跟上。
这套代码的关键就三点:查询失败返回 null 让业务降级、国家码映射集中在一张表里、结果落地缓存。出海 App 换国家切换策略是常态,把这三处做好,后面接多少国家都不用改结构。
四、落地注意事项
- 缓存与刷新节奏:免费接口 60 次/分钟/IP,启动查一次 + 回前台偶尔刷新完全够用;别在
build里调接口,也别每个页面都查。 - 合规规则服务端兜底:IP 国家只是初判,用户可能挂代理、可能人在境外用国内卡。涉及年龄、内容区域、支付等硬规则,最终以服务端下发的配置和风控结果为准,客户端别把开关写死。
- 海外字段为空别硬拼:
prov、city只有国内 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