运动App后台持续定位生成轨迹

栏目: 数据库 · 发布时间: 5年前

内容简介:1.定位LocationService,另起进程同时创建守卫进程Service, LocationHelperService,Service挂掉时守卫进程唤起LocationService。这里因为 LocationService、LocationHelperService、主进程分别为不同的进程,所以需要用AIDL通过进程间的交互。2.LocationService需要触发利用notification增加进程优先级

1. 连续定位采集点

1.定位LocationService,另起进程同时创建守卫进程Service, LocationHelperService,Service挂掉时守卫进程唤起LocationService。

package com.yxc.barchart.map.location.service;

import android.app.Service;
import android.content.ComponentName;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.os.RemoteException;
import androidx.annotation.Nullable;
import com.yxc.barchart.map.location.util.Utils;

public class LocationHelperService extends Service {
    private Utils.CloseServiceReceiver mCloseReceiver;
    @Override
    public void onCreate() {
        super.onCreate();
        startBind();
        mCloseReceiver = new Utils.CloseServiceReceiver(this);
        registerReceiver(mCloseReceiver, Utils.getCloseServiceFilter());
    }
    @Override
    public void onDestroy() {
        if (mInnerConnection != null) {
            unbindService(mInnerConnection);
            mInnerConnection = null;
        }
        if (mCloseReceiver != null) {
            unregisterReceiver(mCloseReceiver);
            mCloseReceiver = null;
        }
        super.onDestroy();
    }

    private ServiceConnection mInnerConnection;
    private void startBind() {
    final String locationServiceName = "com.yxc.barchart.map.location.service.LocationService";
        mInnerConnection = new ServiceConnection() {
            @Override
            public void onServiceDisconnected(ComponentName name) {
                Intent intent = new Intent();
                intent.setAction(locationServiceName);
                startService(Utils.getExplicitIntent(getApplicationContext(), intent));
            }
            @Override
            public void onServiceConnected(ComponentName name, IBinder service) {
                ILocationServiceAIDL l = ILocationServiceAIDL.Stub.asInterface(service);
                try {
                    l.onFinishBind();
                } catch (RemoteException e) {
                    e.printStackTrace();
                }
            }
        };

        Intent intent = new Intent();
        intent.setAction(locationServiceName);
        bindService(Utils.getExplicitIntent(getApplicationContext(), intent), mInnerConnection, Service.BIND_AUTO_CREATE);
    }

    private HelperBinder mBinder;
    private class HelperBinder extends ILocationHelperServiceAIDL.Stub{
        @Override
        public void onFinishBind(int notiId) throws RemoteException {
           startForeground(notiId, Utils.buildNotification(LocationHelperService.this.getApplicationContext()));
            stopForeground(true);
        }
    }
    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        if (mBinder == null) {
            mBinder = new HelperBinder();
        }
        return mBinder;
    }
}

这里因为 LocationService、LocationHelperService、主进程分别为不同的进程,所以需要用AIDL通过进程间的交互。

2.LocationService需要触发利用notification增加进程优先级

/**
 * 触发利用notification增加进程优先级
 */
protected void applyNotiKeepMech() {
  startForeground(NOTI_ID, Utils.buildNotification(getBaseContext()));
  startBindHelperService();
}

这里看Keep等运动App也都是这样,没有加在 Android8.0系统的手机上,home键到后台,就没在定位了,定位listener总是返回缓存的上次定位的Location经纬度。

3.屏熄断网电量屏幕

在定位服务中检测是否是由息屏造成的网络中断,如果是,则尝试进行点亮屏幕。同时,为了避免频繁点亮,对最小时间间隔进行了设置(可以按需求修改). 如果息屏没有断网,则无需点亮屏幕.

AMapLocationListener locationListener = new AMapLocationListener() {
        @Override
        public void onLocationChanged(AMapLocation aMapLocation) {
            //发送结果的通知
            sendLocationBroadcast(aMapLocation);
                 //判断是否需要对息屏断wifi的情况进行处理
            if (!mIsWifiCloseable) {
                return;
            }
                    //将定位结果和设备状态一起交给mWifiAutoCloseDelegate
            if (aMapLocation.getErrorCode() == AMapLocation.LOCATION_SUCCESS) {
                //...
            } else {
               //...
            }
        }
        private void sendLocationBroadcast(AMapLocation aMapLocation) {
            //记录信息并发送广播...
        }
    };

/** 处理息屏后wifi断开的逻辑*/
public class WifiAutoCloseDelegate implements IWifiAutoCloseDelegate {
    /**
     * 请根据后台数据自行添加。此处只针对小米手机
     * @param context
     * @return
     */
    @Override
    public boolean isUseful(Context context) {
       //...
    }

    /** 由于服务可能被杀掉,所以在服务初始化时,初始相关参数*/
    @Override
    public void initOnServiceStarted(Context context) {
        //...
    }

    /** 处理定位成功的信息*/s
    @Override
    public void onLocateSuccess(Context context, boolean isScreenOn, boolean isMobileable) {
        //...
    }

    /** 处理定位失败的信息。如果需要唤醒屏幕,则尝试唤醒*/
    @Override
    public void onLocateFail(Context context, int errorCode, boolean isScreenOn, boolean isWifiable) {
        //...
    }
}

点亮屏幕时,会利用最小间隔时间加以限制

/**
     * 唤醒屏幕
     */
    public void wakeUpScreen(final Context context) {
        try {
            acquirePowerLock(context, PowerManager.ACQUIRE_CAUSES_WAKEUP | PowerManager.SCREEN_DIM_WAKE_LOCK);
        } catch (Exception e) {
            throw e;
        }
    }

/**
     * 根据levelAndFlags,获得PowerManager的WaveLock
     * 利用worker thread去获得锁,以免阻塞主线程
     * @param context
     * @param levelAndFlags
     */
    private void acquirePowerLock(final Context context, final int levelAndFlags) {
        if (context == null) {
            throw new NullPointerException("when invoke aquirePowerLock ,  context is null which is unacceptable");
        }

        long currentMills = System.currentTimeMillis();

        if (currentMills - mLastWakupTime < mMinWakupInterval) {
            return;
        }
        mLastWakupTime = currentMills;

        if (mInnerThreadFactory == null) {
            mInnerThreadFactory = new InnerThreadFactory();
        }
        mInnerThreadFactory.newThread(new Runnable() {
            @Override
            public void run() {
                if (pm == null) {
                    pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
                }

                if (pmLock != null) { // release
                    pmLock.release();
                    pmLock = null;
                }

                pmLock = pm.newWakeLock(levelAndFlags, "MyTag");
                pmLock.acquire();
                pmLock.release();
            }
        }).start();
    }

以上涉及到几个权限:

<!--允许程序访问CellID或WiFi热点来获取粗略的位置-->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

6.0以上需要自己处理动态权限。

2. 存储Location采集点

1.对GPS采集到的点进行加工处理

AMapLocationListener locationListener = new AMapLocationListener() {
        @Override
        public void onLocationChanged(AMapLocation aMapLocation) {
          //有时候会返回(0,0)点需要排除
            if (aMapLocation.getLatitude() == 0f || aMapLocation.getLongitude() <= 0.001f) {
                return;
            }
         //计算跟上一个点的距离,当距离不变时或者变化太小时视为同一点,不在inter新的点,而是修改当前点的endTime以及duration。
         double itemDistance = LocationComputeUtil.getDistance(aMapLocation, lastSaveLocation);
            if (lastSaveLocation == null && aMapLocation.getLatitude() > 0f) {
                //record的第一个埋点,插入数据库
                lastSaveLocation = aMapLocation;
            } else if (itemDistance > 1.0f) {
                resetIntervalTimes(0);//新的点
                lastSaveLocation = aMapLocation;
            } else {//可能在原地打点,不存入新数据,update endTime。
                long timestamp = lastSaveLocation.getTime();
                long endTime = System.currentTimeMillis();//todo 需要考虑定位时间跟系统时间的差值。
                long duration = endTime - timestamp;
                resetIntervalTimes(duration);
            }
            sendLocationBroadcast(aMapLocation);
            //发送结果的通知
            .....
        }

//计算跟上一个点的距离,当距离不变时或者变化太小时视为同一点,不在inter新的点,而是修改当前点的endTime以及duration。

第一个点直接存储。存储原始点的数据结构:

Timestamp   endTime duration    Latitude    Longitude   speed   itemDistance    distance    locationStr recordType  recordId    milePost

recordType:运动记录类型

recordId: 运动记录Id (通过查找同一 ID以及 recordType下的所有点,然后绘制运动轨迹)

itemDistance 为跟上一个存储点的距离,distance表示跟起始点的距离,可以用来标记里程碑,milePost用来标记里程碑点。endTime、duration可以用来标志该位置停留的时间。

locationStr:包含了当前点的latitude、longitude等重要字段,可供反向解析成AMapLocation。

resetIntervalTimes(duration)方法根据duration的时间长短有各自不同的策略:比如调整定位采集Location的频率,或者到某一时间段就直接停止LocationService

//LocationService

private long intervalTime = LocationConstants.DEFAULT_INTERVAL_TIME;
     private void resetIntervalTimes(long duration) {
       if (duration >= 60 * 60 * 1000){// 90分钟停止自己的服务, 应该还要关闭守护进程
         onDestroy();
         return;
       }
       int intervalTimes = LocationComputeUtil.computeIntervalTimes(duration);
       intervalTime = intervalTimes * LocationConstants.DEFAULT_INTERVAL_TIME;
       mLocationOption.setInterval(intervalTime);
       mLocationClient.setLocationOption(mLocationOption);
  }

 public static int computeIntervalTimes(long duration) {
        long timeMin = 60 * 1000;
        if (duration > timeMin) {
            return 2;
        } else if (duration > 4 * timeMin) {
            return 3;
        } else if (duration > 10 * timeMin) {
            return 5;
        }
        return 1;
  }

这里其实可以当duration大于某个时间段值时就stopservice,然后通过陀螺仪、计步器等其他手段来重新唤起GPS定位。

以上是LocationService采集点,因为它在remote1的进程中,所以没有在这里进行DB的insert以及update的操作,而是发出广播给LocalLocationService,一下是LocalLocationService收到broadcast时,数据存储过程:

//LocalLocationService

public void onLocationChanged(AMapLocation aMapLocation) {
  if (aMapLocation.getLatitude() == 0f || aMapLocation.getLongitude() <= 0.001f) {
    return;
  }
  //计算当前定位点跟上一保存点的距离为itemDistance,当lastSaveLocation为null时itemDistance为0.
  double itemDistance = LocationComputeUtil.getDistance(aMapLocation, lastSaveLocation);
  if (lastSaveLocation == null && aMapLocation.getLatitude() > 0f) {
    //record的第一个埋点,插入数据库
    Log.d("LocationService", "第一个点。。。");
    Toast.makeText(LocalLocationService.this, "Service first insert Point",        Toast.LENGTH_SHORT).show();
    LocationDBHelper.deleteRecordLocationList(recordType, recordId);
    String locationStr = LocationComputeUtil.amapLocationToString(aMapLocation);
    double distance = 0;
    double milePost = 0;
    RecordLocation recordLocation = RecordLocation.createLocation(aMapLocation, recordId, recordType, itemDistance, distance, locationStr, milePost);
    //首个点直接insert到数据库
    LocationDBHelper.insertRecordLocation(recordLocation);

    Log.d("LocationService", "first insert recordLocation:" + recordLocation.toString());
    sendEventbus(aMapLocation, recordLocation);
    //有insert操作时就更新当前Location为 lastSaveLocation点,供下次计算使用。
    lastSaveLocation = aMapLocation;
    lastRecordLocation = recordLocation;
  } else if (itemDistance > 1.0f) {
    Toast.makeText(LocalLocationService.this, "save Point:" + aMapLocation.getLatitude(), Toast.LENGTH_SHORT).show();
    String locationStr = LocationComputeUtil.amapLocationToString(aMapLocation);
    if (lastRecordLocation != null) {
      //根据上一个保存Location值累计计算 当前点的 distance值。
      double distance = lastRecordLocation.distance + itemDistance;
      double milePost = 0;
      if (distance >= mMilePost){//当刚好大于里程碑点时就记录该点为里程碑点,并修改里程碑值,等待记录下一里程碑点。
        milePost = mMilePost;
        mMilePost += LocationConstants.MILE_POST_ONE_KILOMETRE;
      }
      RecordLocation recordLocation = RecordLocation.createLocation(aMapLocation, recordId, recordType,itemDistance, distance, locationStr, milePost);
      //当前Location的time 跟 上个点的endTime 同 itemDistance计算得到当前点的Speed值。
      long time = (aMapLocation.getTime() - lastRecordLocation.endTime)/1000;
      float speed = (float) (itemDistance * 1.0f/time);
      recordLocation.speed = speed;
      lastRecordLocation = recordLocation;
      //insert
      LocationDBHelper.insertRecordLocation(recordLocation);

      //修改lastSaveLocation的endTime, duration。
      // long lastSaveLocationEndTime = aMapLocation.getTime();
      //long lastSaveLocationDuration = aMapLocation.getTime() - lastSaveLocation.getTime();         //LocationDBHelper.updateRecordLocation(lastSaveLocation.getTime(),lastSaveLocationEndTime, 
      //lastSaveLocationDuration);
      sendEventbus(aMapLocation, recordLocation);
      Log.d("LocationService", "insert recordLocation:" + recordLocation.toString());
    }
    lastSaveLocation = aMapLocation;
  } else {//可能在原地打点,不存入新数据,update endTime。
    Toast.makeText(LocalLocationService.this, "update Point:" + aMapLocation.getLatitude(), Toast.LENGTH_SHORT).show();
    long timestamp = lastSaveLocation.getTime();
    long endTime = System.currentTimeMillis();//todo 需要考虑定位时间跟系统时间的差值。
    long duration = endTime - timestamp;
    //更新LastSaveLocation的 endTime,duration值
    LocationDBHelper.updateRecordLocation(timestamp, endTime, duration);
  }
}

LocalLocationService跟LocationService一样分三种情况:

1.首次记录 直接create RecordLocation然后 insert到数据库

2.Location变化时插入新的点;根据保存的 lastSaveLocation计算新的变化的Location点ItemDistance, distance,speed, 以及看它是否是里程碑点,写入milePost字段。具体操作参考以上代码注释。

3.变化范围小时,视为当前点修改endtime、duration。

当有新的数据插入时,就更新UI,通过EventBus传递RecordLocation

private void sendEventbus(AMapLocation aMapLocation, RecordLocation recordLocation) {
        //改成发送eventBus
      EventBus.getDefault().post(new LocationEvent(aMapLocation, recordLocation));
}

3. 绘制RecordPath

LocationActivity收到EventBus时,可以使用EventBus传过来的数据,也可以从DB中拿取点进行实时的绘制。

//eventBus 接受 LocalService 传过来的数据
    @Subscribe
    public void onLocationSaved(LocationEvent locationEvent){
        if (locationEvent.mapLocation != null){
            onLocationChanged(locationEvent.mapLocation, locationEvent.recordLocation);
        }
    }

这里我是从数据库里面拿取点,通过查询 timestamp greaterThan 来获取LocationList,然后进行绘制轨迹。

/**
 * 定位结果回调
 *
 * @param amapLocation 位置信息类
 */
public void onLocationChanged(AMapLocation amapLocation, RecordLocation sendRecordLocation) {
  if (amapLocation != null && amapLocation.getErrorCode() == 0) {
    if (lastLocation != null) {
      long timestamp = lastLocation.getTime();
      //从数据库里拿点
     List<RecordLocation> locationList 
       = LocationDBHelper.getLateLocationList(recordId, timestamp);
      record.addPointList(locationList);
      for (int i = 0; i < locationList.size(); i++) {
       RecordLocation recordLocation = locationList.get(i);
       AMapLocation aMapLocation=LocationComputeUtil.parseLocation(recordLocation.locationStr);
       LatLng myLocation = new LatLng(aMapLocation.getLatitude(), aMapLocation.getLongitude());
       mPolyOptions.add(myLocation);
      }
      redRawLine();
    } else {//第一个点直接绘制
      LatLng myLocation = new LatLng(amapLocation.getLatitude(), amapLocation.getLongitude());
      mAMap.moveCamera(CameraUpdateFactory.changeLatLng(myLocation));
      if (btn.isChecked()) {
        Log.d("Location", "record " + myLocation);
        record.addPoint(sendRecordLocation);
        mPolyOptions.add(myLocation);
        redRawLine();
      }
    }
    lastLocation = amapLocation;
  } else {
    String errText = "定位失败," + amapLocation.getErrorCode() + ": "
      + amapLocation.getErrorInfo();
    Log.e("AmapErr", errText);
  }
}

绘制实时轨迹

/**
 * 实时轨迹画线
 */
private void redRawLine() {
  if (mPolyOptions.getPoints().size() > 1) {
    if (mPolyline != null) {
      mPolyline.setPoints(mPolyOptions.getPoints());
    } else {
      mPolyline = mAMap.addPolyline(mPolyOptions);
    }
  }
}

保存轨迹路径到Model Record中,并且存入数据库:

if (!TextUtils.isEmpty(recordId)){
    //根据recordType, recordId查询原始LocationList
     List<RecordLocation> locationList = LocationDBHelper.getLocationList(recordType,recordId);
     saveRecord(locationList);
 }
//查询所有点,然后生成Record对象,插入Record对象到DB

protected void saveRecord(List<RecordLocation> list) {
  if (list != null && list.size() > 0) {
    RecordLocation firstLocation = list.get(0);
    RecordLocation lastLocation = list.get(list.size() - 1);
    double distance = lastLocation.distance;
    long duration = getDuration(firstLocation, lastLocation);
    String averageSpeed = getAverage(distance, duration);
    String pathLineStr = LocationComputeUtil.getPathLineStr(list);
    String dateStr = TimeDateUtil.getDateStrMinSecond(firstLocation.getTimestamp());

    Record record = Record.createRecord(recordType, Double.toString(distance),Long.toString(duration), averageSpeed, pathLineStr,firstLocation.locationStr,lastLocation.locationStr, dateStr);
    LocationDBHelper.insertRecord(record);
  } else {
    Toast.makeText(LocationActivity.this, "没有记录到路径", Toast.LENGTH_SHORT).show();
  }
}

private long getDuration(RecordLocation firstLocation, RecordLocation lastLocation) {
  return (lastLocation.getEndTime() - firstLocation.getTimestamp()) / 1000;
}

private String getAverage(double distance, long duration) {
  return String.valueOf(distance/duration);
}

Record是跟估计路径相关的Model,包含了所有Location点的数据,通过Gson 转化 数据库查询出来的LocationList转化而来,这里开发过程中遇到点问题:我用的数据库是Realm,数据库查询出来的是RecordLocationProxy对象,而并非RecordLocation对象导致Gson转换出问题,所以我这里直接将RecordLocationProxy 进行DeepClone成 RecordLocation,然后这里是一个List,处理每个对象的每个字段,效率不是很好,暂时没有想到好的方案。

同时生成的Gson对象也需要对RealmObject的字段进行过滤等操作,关于Realm数据库相关的知识不是本篇的着重点,所以不做介绍。到时看能否找到解决以上问题的方法时单独来写介绍。

public class Record extends RealmObject {
  @PrimaryKey
  public int id;
  public int recordType;
  public String distance;
  public String duration;
  public String speed;//这里是averageSpeed
  public String pathLine;//所有点的Gson字符串。
  @Ignore
  private AMapLocation mStartPoint;
  @Ignore
  private AMapLocation mEndPoint;
  public String startPoint;//起始点的重要字段字符串,对应RecordLocation的LocationStr
  public String endPoint;//结束点的重要字段字符串,对应RecordLocation的LocationStr
  public String date;//起始点对应Timestamp的dateStr

  @Ignore
  public List<RecordLocation> mPathLocationList = new ArrayList<>();
  public Record() {
  }

  public static Record createRecord(int recordType, String distance, String duration, String speed, String pathLine, String startPoint,String endPoint, String date) {
    Record record = new Record();
    record.recordType = recordType;
    record.distance = distance;
    record.duration = duration;
    record.speed = speed;
    record.pathLine = pathLine;
    record.startPoint = startPoint;
    record.endPoint = endPoint;
    record.date = date;
    return record;
  }
  ......
}

RecordLocation单点的数据结构,对应以上介绍过的数据表:

public class RecordLocation extends RealmObject {

    @PrimaryKey
    public long timestamp;//时间戳
    public long endTime;//当前点待了多久,用 endTime - timestamp = duration。
    public long duration;
    public double longitude;//精度
    public double latitude;//维度
    public float speed;//单点的速度,用来划线的时候上不同的颜色
    public double itemDistance;//距离上一个点的距离
    public double distance;//距离起始点的距离
    public String recordId;//运动记录 id(用于聚合查询)
    public int recordType;//运动类型,跑步,骑行,驾驶。
    public String locationStr;//包含AMapLocation的字段
    public double milePost;//里程碑

    public RecordLocation() {

    }

    public static RecordLocation copyLocation(RecordLocation originalLocation){
        RecordLocation recordLocation = new RecordLocation();
        recordLocation.timestamp = originalLocation.getTimestamp();
        recordLocation.endTime = originalLocation.getEndTime();
        recordLocation.duration = originalLocation.getDuration();
        recordLocation.latitude = originalLocation.getLatitude();
        recordLocation.longitude = originalLocation.getLongitude();
        recordLocation.speed = originalLocation.getSpeed();
        recordLocation.recordId = originalLocation.getRecordId();
        recordLocation.recordType = originalLocation.getRecordType();
        recordLocation.itemDistance = originalLocation.getItemDistance();
        recordLocation.distance = originalLocation.getDistance();
        recordLocation.locationStr = originalLocation.getLocationStr();
        recordLocation.milePost=originalLocation.getMilePost();
        return recordLocation;
    }

    public static RecordLocation createLocation(AMapLocation location, String recordId,
                                                int recordType, double itemDistance,
                                                double distance, String locationStr, double milePost){
        RecordLocation recordLocation = new RecordLocation();
        recordLocation.timestamp = location.getTime();
        recordLocation.endTime = recordLocation.timestamp;
        recordLocation.duration = 0;
        recordLocation.latitude = location.getLatitude();
        recordLocation.longitude = location.getLongitude();
        recordLocation.speed = location.getSpeed();
        recordLocation.recordId = recordId;
        recordLocation.recordType = recordType;
        recordLocation.itemDistance = itemDistance;
        recordLocation.distance = distance;
        recordLocation.locationStr = locationStr;
        recordLocation.milePost = milePost;
        return recordLocation;
    }

    @Override
    public String toString() {
        return "RecordLocation{" +
                "timestamp=" + timestamp +
                ", endTime=" + endTime +
                ", duration=" + duration +
                ", longitude=" + longitude +
                ", latitude=" + latitude +
                ", speed=" + speed +
                ", itemDistance=" + itemDistance +
                ", distance=" + distance +
                ", recordId='" + recordId + '\'' +
                ", recordType=" + recordType +
                ", locationStr='" + locationStr + '\'' +
                ", milePost=" + milePost +
                '}';
    }

  。。。。
}

总结

数据采集,存储,绘制流程不复杂,测试过程比较繁琐,需要到外头采集GPS点;调试起来相对而言就不是很方便,之前Android8.0以上的后天,熄屏等造成的不能连续打点的问题;以及采集到数据后,不能一目了然的看到原始数据等诸多阻碍流程的问题在坚持不懈的努力下得到解决。这里的原始数据查看,我是用的Realm的 工具Reaml Browser查看数据库文件 **.remal文件的方法进行,还是蛮方便的。这里我设置了Realm的Config直接存储文件到SD卡下,不过这里有个权限的问题就是,RealmConfig的配置指定SD卡读写是在Application onCreate中,6.0以上的动态权限还没有来得及申请,同样这个问题以后留到Realm单独深究以后做介绍。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

互联网黑洞

互联网黑洞

仲昭川 / 电子工业出版社 / 2014-4 / 50.00

万物之灵,存乎一心;互联网时代,上兵伐谋。 纵横古今商业奥秘,无非兴趣与利益、诱惑与满足、成本与利润、价格与价值。 本书着眼于大互联网时代,旨在通过对时下互联网圈子的冷静分析、传奇披露、实战揭秘,进而传授互联网哲学,阐述互联网现状,揭示互联网价值,尝试为互联网的未来探寻狭窄的通道。一起来看看 《互联网黑洞》 这本书的介绍吧!

图片转BASE64编码
图片转BASE64编码

在线图片转Base64编码工具

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

MD5 加密
MD5 加密

MD5 加密工具