18、后台定位:Android 8.0后台限制、前台服务定位、通知栏常驻提示

后台定位,说白了就是App退到后台了,还得继续拿位置。

Android 8.0之前,这事儿挺简单的。你开个Service,在后台默默收位置就行。但8.0之后,Google搞了个大动作——后台执行限制。我当年第一次适配8.0时,差点被搞崩溃。App一进后台,定位就停了,用户投诉说“走路轨迹断了”。嗯,从那以后,我彻底搞懂了前台服务定位这套东西。

18.1 Android 8.0的后台限制到底限了什么?

Android 8.0(API 26)引入了后台服务限制。核心就一句话:后台应用不能随便启动后台Service

具体来说:

  • App处于后台状态时,不能通过startService()启动后台Service
  • 已经运行的后台Service,系统可能会在几分钟内把它停掉
  • 只有绑定到前台通知的Service(即前台服务)才能长期存活

为什么会这样?说白了,Google是为了省电和防止流氓App。你想想看,如果每个App都在后台疯狂拿位置,手机半天就没电了。

但问题来了——导航、运动记录、打车这类App,确实需要在后台持续定位。怎么办?Google留了个口子:前台服务

核心结论:Android 8.0之后,后台持续定位的唯一官方方案就是前台服务 + 通知栏常驻提示。

18.2 前台服务定位:这才是正统方案

前台服务,本质上还是一个Service。但它有一个特殊之处:必须显示一个通知栏通知,告诉用户“这个App正在后台干活”。

用户看到通知栏有个定位图标,就知道你在拿位置。这是Android 8.0强制要求的,不能隐藏。

我个人的习惯是,在定位前台服务里同时做两件事:

  • 启动前台服务(startForeground)
  • 绑定位置请求(FusedLocationProviderClient)

下面是一个完整的前台定位Service示例:

public class LocationForegroundService extends Service {

    private static final int NOTIFICATION_ID = 1001;
    private static final String CHANNEL_ID = "location_channel";

    private FusedLocationProviderClient fusedLocationClient;
    private LocationCallback locationCallback;

    @Override
    public void onCreate() {
        super.onCreate();
        fusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
        createNotificationChannel();
        startForeground(NOTIFICATION_ID, buildNotification("正在获取位置..."));

        locationCallback = new LocationCallback() {
            @Override
            public void onLocationResult(LocationResult locationResult) {
                if (locationResult == null) return;
                for (Location location : locationResult.getLocations()) {
                    // 处理位置数据,比如保存到数据库或上传服务器
                    updateNotification("纬度: " + location.getLatitude()
                            + ", 经度: " + location.getLongitude());
                }
            }
        };
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        requestLocationUpdates();
        return START_STICKY;
    }

    private void requestLocationUpdates() {
        LocationRequest locationRequest = LocationRequest.create()
                .setInterval(5000)           // 5秒一次
                .setFastestInterval(2000)    // 最快2秒
                .setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

        if (ActivityCompat.checkSelfPermission(this,
                Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
            fusedLocationClient.requestLocationUpdates(locationRequest,
                    locationCallback, Looper.getMainLooper());
        }
    }

    private void createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationChannel channel = new NotificationChannel(
                    CHANNEL_ID,
                    "定位服务",
                    NotificationManager.IMPORTANCE_LOW
            );
            channel.setDescription("用于后台定位的通知渠道");
            NotificationManager manager = getSystemService(NotificationManager.class);
            if (manager != null) {
                manager.createNotificationChannel(channel);
            }
        }
    }

    private Notification buildNotification(String content) {
        return new NotificationCompat.Builder(this, CHANNEL_ID)
                .setContentTitle("正在定位")
                .setContentText(content)
                .setSmallIcon(android.R.drawable.ic_menu_mylocation)
                .setOngoing(true)  // 不可滑动删除
                .build();
    }

    private void updateNotification(String content) {
        NotificationManager manager = getSystemService(NotificationManager.class);
        if (manager != null) {
            manager.notify(NOTIFICATION_ID, buildNotification(content));
        }
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        if (fusedLocationClient != null && locationCallback != null) {
            fusedLocationClient.removeLocationUpdates(locationCallback);
        }
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}

个人经验:START_STICKY这个flag很重要。如果系统因为资源紧张杀了你的前台服务,START_STICKY会让系统尽量重启它。但别指望100%重启——极端情况下系统可能直接放弃。

18.3 通知栏常驻提示:不只是为了合规

通知栏常驻提示,很多人觉得是累赘。但我告诉你,它其实是个好东西。

第一,它让用户知道你在定位。用户如果不想被定位,可以手动关掉通知,或者杀掉App。这是隐私保护的基本要求。

第二,它提高了App的存活率。前台服务的进程优先级很高,系统轻易不会杀它。相比后台Service,前台服务的保活能力强太多了。

第三,你可以在通知里展示实时信息。比如导航App显示“距离目的地还有2公里”,运动App显示“已跑步5公里”。这本身就是一种用户体验优化。

我曾经在一个骑行App里,把通知栏做成了迷你仪表盘:显示速度、里程、海拔。用户不用打开App就能看到关键数据,反馈特别好。

18.4 权限与适配要点

前台服务定位需要两个权限:

权限 说明 备注
ACCESS_FINE_LOCATION 精确定位(GPS) 必须运行时申请
FOREGROUND_SERVICE 前台服务权限 Android 9+ 需要声明
ACCESS_BACKGROUND_LOCATION 后台定位权限 Android 10+ 需要单独申请

注意,Android 10(API 29)之后,后台定位权限变成了一个独立权限。用户必须在设置里手动开启,不能只靠运行时弹窗申请。我见过很多App因为这个被用户投诉“定位不准”——其实就是没拿到后台权限。

避坑指南:我曾经踩过一个坑——在Android 10上,即使你用了前台服务,如果没申请ACCESS_BACKGROUND_LOCATION,系统仍然会在一段时间后停止位置更新。解决方案是:在用户首次开启定位时,引导用户去设置里开启“始终允许”定位权限。

18.5 知识体系图:后台定位核心逻辑

下面这张图,帮你理清Android 8.0后台定位的整体脉络:

Android 8.0+ 后台定位核心逻辑 后台定位需求 Android 8.0+? 后台Service (已废弃) 前台服务 + 通知 startForeground() 通知栏常驻 持续获取位置更新

18.6 实战建议:如何优雅地实现后台定位

根据我多年的踩坑经验,给你几条实战建议:

  1. 通知要友好——别只写“正在定位”,写点有用的信息,比如“已记录轨迹2.3公里”。用户看了不反感。
  2. 合理设置定位间隔——导航场景用高精度+1秒间隔,运动记录用5秒间隔,签到场景用30秒间隔。别一上来就最高频率,耗电快用户会骂。
  3. 处理好生命周期——用户主动停止定位时,记得调stopForeground()和stopSelf()。别让Service在后台空转。
  4. 适配Android 12+——Android 12引入了前台服务启动限制,后台不能直接startForegroundService()。建议用WorkManager或JobScheduler来触发。

我的习惯:在定位Service里加一个心跳检测。如果5分钟没收到位置更新,自动降级为低功耗模式,或者弹通知提醒用户检查GPS开关。这个小功能帮我解决了不少用户反馈的“定位不动”问题。

好了,后台定位这块内容就讲到这里。核心就一句话:Android 8.0之后,老老实实用前台服务+通知栏,别想走捷径。适配好各个版本的权限变化,你的App才能在后台稳稳地拿位置。


公众号:蓝海资料掘金营,微信deep3321