将您的 Parse Android 应用迁移到 Firebase

如果您是一位 Parse 用户,正在寻找后端即服务解决方案,用以替代以前的解决方案,Firebase 可能是您的 Android 应用的理想选择。

本指南介绍如何将特定服务集成到您的应用中。如需查看 Firebase 的基本设置说明,请参阅 Android 设置指南。

Google Analytics(分析)

Google Analytics(分析)是一款免费的应用衡量解决方案,可提供关于应用使用情况和用户互动度的数据分析。Google Analytics(分析)与各种 Firebase 功能集成,可以就多达 500 种不同事件(您可以利用 Firebase SDK 定义这些事件)无限制地向您提供报告。

如需了解详情,请参阅 Google Analytics(分析)文档

建议的迁移策略

使用不同的分析提供程序是非常普遍的现象,Google Analytics(分析)能够轻松适应这种情况。只需将 Analytics(分析)添加至您的应用,它即会自动收集事件和用户属性(如首次打开、应用更新、设备型号、年龄等),让您从中受益。

对于自定义的事件和用户属性,您可采取双写策略,同时使用 Parse Analytics 和 Google Analytics(分析)记录事件和属性,进而逐步过渡到新解决方案。

代码比较

Parse Analytics

// Start collecting data
ParseAnalytics.trackAppOpenedInBackground(getIntent());

Map<String, String> dimensions = new HashMap<String, String>();
// Define ranges to bucket data points into meaningful segments
dimensions.put("priceRange", "1000-1500");
// Did the user filter the query?
dimensions.put("source", "craigslist");
// Do searches happen more often on weekdays or weekends?
dimensions.put("dayType", "weekday");

// Send the dimensions to Parse along with the 'search' event
ParseAnalytics.trackEvent("search", dimensions);

Google Analytics(分析)

// Obtain the FirebaseAnalytics instance and start collecting data
mFirebaseAnalytics = FirebaseAnalytics.getInstance(this);

Bundle params = new Bundle();
// Define ranges to bucket data points into meaningful segments
params.putString("priceRange", "1000-1500");
// Did the user filter the query?
params.putString("source", "craigslist");
// Do searches happen more often on weekdays or weekends?
params.putString("dayType", "weekday");

// Send the event
mFirebaseAnalytics.logEvent("search", params);

Firebase Realtime Database

Firebase Realtime Database 是一种 NoSQL 云端托管数据库。数据以 JSON 格式存储并实时同步到所连接的每个客户端。

如需了解详情,请参阅 Firebase Realtime Database 文档

与 Parse 数据的差异

对象

在 Parse 中,您存储的是一个 ParseObject 或其子类,其中包含 JSON 兼容数据的键值对。数据是无架构的,这意味着您无需指定在每个 ParseObject 上存在什么键。

所有 Firebase Realtime Database 数据均被存储为 JSON 对象,没有与 ParseObject 对应的形式;您只需将数据写入与可用 JSON 类型对应的不同类型的 JSON 树值。您可以使用 Java 对象简化该数据库的读写操作。

以下示例说明如何保存一款游戏的最高得分:

Parse
@ParseClassName("GameScore")
public class GameScore {
        public GameScore() {}
        public GameScore(Long score, String playerName, Boolean cheatMode) {
            setScore(score);
            setPlayerName(playerName);
            setCheatMode(cheatMode);
        }

        public void setScore(Long score) {
            set("score", score);
        }

        public Long getScore() {
            return getLong("score");
        }

        public void setPlayerName(String playerName) {
            set("playerName", playerName);
        }

        public String getPlayerName() {
            return getString("playerName");
        }

        public void setCheatMode(Boolean cheatMode) {
            return set("cheatMode", cheatMode);
        }

        public Boolean getCheatMode() {
            return getBoolean("cheatMode");
        }
}

// Must call Parse.registerSubclass(GameScore.class) in Application.onCreate
GameScore gameScore = new GameScore(1337, "Sean Plott", false);
gameScore.saveInBackground();
Firebase
// Assuming we defined the GameScore class as:
public class GameScore {
        private Long score;
        private String playerName;
        private Boolean cheatMode;

        public GameScore() {}
        public GameScore(Long score, String playerName, Boolean cheatMode) {
            this.score = score;
            this.playerName = playerName;
            this.cheatMode = cheatMode;
        }

        public Long getScore() {
            return score;
        }

        public String getPlayerName() {
            return playerName;
        }

        public Boolean getCheatMode() {
            return cheatMode;
        }
}

// We would save it to our list of high scores as follows:
DatabaseReference mFirebaseRef = FirebaseDatabase.getInstance().getReference();
GameScore score = new GameScore(1337, "Sean Plott", false);
mFirebaseRef.child("scores").push().setValue(score);
如需了解详情,请参阅在 Android 上读取和写入数据指南。

数据之间的关系

ParseObject 可与另一个 ParseObject 建立关系,即任何对象都可以使用其他对象作为值。

在 Firebase Realtime Database 中,最好使用将数据拆分至不同路径的平展型数据结构来表现数据关系,以便通过不同的调用高效地下载数据。

以下示例说明如何组织博客应用中博文与其作者之间的关系:

Parse
// Create the author
ParseObject myAuthor = new ParseObject("Author");
myAuthor.put("name", "Grace Hopper");
myAuthor.put("birthDate", "December 9, 1906");
myAuthor.put("nickname", "Amazing Grace");

// Create the post
ParseObject myPost = new ParseObject("Post");
myPost.put("title", "Announcing COBOL, a New Programming Language");

// Add a relation between the Post and the Author
myPost.put("parent", myAuthor);

// This will save both myAuthor and myPost
myPost.saveInBackground();
Firebase
DatabaseReference firebaseRef = FirebaseDatabase.getInstance().getReference();
// Create the author
Map<String, String> myAuthor = new HashMap<String, String>();
myAuthor.put("name", "Grace Hopper");
myAuthor.put("birthDate", "December 9, 1906");
myAuthor.put("nickname", "Amazing Grace");

// Save the author
String myAuthorKey = "ghopper";
firebaseRef.child('authors').child(myAuthorKey).setValue(myAuthor);

// Create the post
Map<String, String> post = new HashMap<String, String>();
post.put("author", myAuthorKey);
post.put("title", "Announcing COBOL, a New Programming Language");
firebaseRef.child('posts').push().setValue(post);

以下数据结构是组织结果。

{
  // Info about the authors
  "authors": {
    "ghopper": {
      "name": "Grace Hopper",
      "date_of_birth": "December 9, 1906",
      "nickname": "Amazing Grace"
    },
    ...
  },
  // Info about the posts: the "author" fields contains the key for the author
  "posts": {
    "-JRHTHaIs-jNPLXOQivY": {
      "author": "ghopper",
      "title": "Announcing COBOL, a New Programming Language"
    }
    ...
  }
}
如需了解详情,请参阅设计数据库的数据结构指南。

读取数据

在 Parse 中,您可使用特定 Parse 对象的 ID 来读取数据,或使用 ParseQuery 执行查询来读取数据。

在 Firebase 中,您可将异步侦听器附加到数据库引用,以此检索数据。该侦听器会针对数据的初始状态触发一次,以后只要数据有更改就会再次触发,因此您无需添加任何代码即可判断数据是否发生变化。

以下示例说明如何根据“对象”部分介绍的示例来检索某个游戏玩家的分数:

Parse
ParseQuery<ParseObject> query = ParseQuery.getQuery("GameScore");
query.whereEqualTo("playerName", "Dan Stemkoski");
query.findInBackground(new FindCallback<ParseObject>() {
    public void done(List<ParseObject> scoreList, ParseException e) {
        if (e == null) {
            for (ParseObject score: scoreList) {
                Log.d("score", "Retrieved: " + Long.toString(score.getLong("score")));
            }
        } else {
            Log.d("score", "Error: " + e.getMessage());
        }
    }
});
Firebase
DatabaseReference mFirebaseRef = FirebaseDatabase.getInstance().getReference();
Query mQueryRef = mFirebaseRef.child("scores").orderByChild("playerName").equalTo("Dan Stemkoski");

// This type of listener is not one time, and you need to cancel it to stop
// receiving updates.
mQueryRef.addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot snapshot, String previousChild) {
        // This will fire for each matching child node.
        GameScore score = snapshot.getValue(GameScore.class);
        Log.d("score", "Retrieved: " + Long.toString(score.getScore());
    }
});
如需详细了解事件侦听器的可用类型及如何对数据排序和过滤,请参阅在 Android 上读取和写入数据指南。

建议的迁移策略

重新评估数据

Firebase Realtime Database 经过优化,可在所有连接的客户端中以毫秒级速度同步数据,其最终数据结构与 Parse 核心数据有很大不同。这意味着,迁移的第一步是考虑您的数据需要进行哪些更改,其中包括:

  • 您的 Parse 对象应如何映射到 Firebase 数据
  • 如果您的数据有父子关系,如何将数据拆分到不同路径,以便通过不同调用高效地下载。

迁移数据

在决定如何在 Firebase 中组织数据之后,您需要计划如何应对您的应用需要向两种数据库写入数据的过渡期。您可以选择以下方案:

后台同步

在此方案中,您有两个应用版本:一个是使用 Parse 的旧版本,另一个是使用 Firebase 的新版本。然后,通过 Parse Cloud Code 处理这两种数据库之间的同步(Parse 到 Firebase),同时,您的代码将在 Firebase 上侦听变化,并与 Parse 同步这些变化。在开始使用新版本之前,您必须:

  • 将现有的 Parse 数据转换为新的 Firebase 结构,并将其写入 Firebase Realtime Database。
  • 编写 Parse Cloud Code 函数,以便使用 Firebase REST API 将旧版客户端在 Parse 数据中产生的变化写入 Firebase Realtime Database。
  • 编写和部署代码,以便侦听 Firebase 上的变化并将其同步到 Parse 数据库。

此方案能确保完全隔离新旧代码,避免让客户端变得复杂。此方案的两大挑战是:处理初始导出的庞大数据集以及保证双向同步不会引发无限递归。

双写

在此方案中,您需要编写一个同时使用 Firebase 和 Parse 的新版应用,使用 Parse Cloud Code 将旧版客户端产生的变化从 Parse 数据同步到 Firebase Realtime Database。当有足够多的用户从 Parse 专用版应用迁移后,您就可以从双写版本中移除 Parse 代码。

此方案不需要任何服务器端代码,但是它的缺点是,未访问的数据不会迁移,并且您的应用大小会因使用两种 SDK 而增大。

Firebase 身份验证

Firebase 身份验证可使用密码和深受欢迎的联合用户身份提供方(如 Google、Facebook 和 Twitter)对用户进行身份验证。它还提供了界面代码库,让您在跨所有平台实现和维护应用的全面身份验证体验方面节省相当可观的必要投资。

请参阅 Firebase 身份验证文档了解详情。

与 Parse 身份验证的差异

Parse 提供一个称为 ParseUser 的专用用户类,可自动处理用户帐号管理所需的功能。ParseUserParseObject 的一个子类,这意味着,用户数据包含在 Parse 数据中,并可像任何其他 ParseObject 一样通过额外的字段进行扩展。

FirebaseUser 具有一组固定的基本属性,即唯一 ID、主电子邮件地址、用户名和照片网址。这些属性存储在单独的项目的用户数据库中,并可由用户更新。您无法向 FirebaseUser 对象直接添加其他属性,但可以将更多属性存储在您的 Firebase Realtime Database 数据库中。

以下示例说明如何注册一个用户并额外添加一个电话号码字段。

Parse
ParseUser user = new ParseUser();
user.setUsername("my name");
user.setPassword("my pass");
user.setEmail("email@example.com");

// other fields can be set just like with ParseObject
user.put("phone", "650-253-0000");

user.signUpInBackground(new SignUpCallback() {
    public void done(ParseException e) {
        if (e == null) {
            // Hooray! Let them use the app now.
        } else {
            // Sign up didn't succeed. Look at the ParseException
            // to figure out what went wrong
        }
    }
});
Firebase
FirebaseAuth mAuth = FirebaseAuth.getInstance();

mAuth.createUserWithEmailAndPassword("email@example.com", "my pass")
    .continueWithTask(new Continuation<AuthResult, Task<Void>> {
        @Override
        public Task<Void> then(Task<AuthResult> task) {
            if (task.isSuccessful()) {
                FirebaseUser user = task.getResult().getUser();
                DatabaseReference firebaseRef = FirebaseDatabase.getInstance().getReference();
                return firebaseRef.child("users").child(user.getUid()).child("phone").setValue("650-253-0000");
            } else {
                // User creation didn't succeed. Look at the task exception
                // to figure out what went wrong
                Log.w(TAG, "signInWithEmail", task.getException());
            }
        }
    });

建议的迁移策略

迁移帐号

要将用户帐号从 Parse 迁移到 Firebase,请将您的用户数据库导出为一个 JSON 或 CSV 文件,然后使用 Firebase CLI 的 auth:import 命令将该文件导入您的 Firebase 项目中。

首先,从 Parse 控制台或您的自托管数据库中导出您的用户数据库。例如,从 Parse 控制台导出的 JSON 文件可能如下所示:

{ // Username/password user
  "bcryptPassword": "$2a$10$OBp2hxB7TaYZgKyTiY48luawlTuYAU6BqzxJfpHoJMdZmjaF4HFh6",
  "email": "user@example.com",
  "username": "testuser",
  "objectId": "abcde1234",
  ...
},
{ // Facebook user
  "authData": {
    "facebook": {
      "access_token": "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
      "expiration_date": "2017-01-02T03:04:05.006Z",
      "id": "1000000000"
    }
  },
  "username": "wXyZ987654321StUv",
  "objectId": "fghij5678",
  ...
}

然后,将导出的文件转换为 Firebase CLI 所需的格式。请将您的 Parse 用户的 objectId 用作 Firebase 用户的 localId。同时,对来自 Parse 的 bcryptPassword 值进行 base64 编码,并将其用在 passwordHash 字段中。例如:

{
  "users": [
    {
      "localId": "abcde1234",  // Parse objectId
      "email": "user@example.com",
      "displayName": "testuser",
      "passwordHash": "JDJhJDEwJE9CcDJoeEI3VGFZWmdLeVRpWTQ4bHVhd2xUdVlBVTZCcXp4SmZwSG9KTWRabWphRjRIRmg2",
    },
    {
      "localId": "fghij5678",  // Parse objectId
      "displayName": "wXyZ987654321StUv",
      "providerUserInfo": [
        {
          "providerId": "facebook.com",
          "rawId": "1000000000",  // Facebook ID
        }
      ]
    }
  ]
}

最后,使用 Firebase CLI 导入已转换的文件,将 bcrypt 指定为哈希算法:

firebase auth:import account_file.json --hash-algo=BCRYPT

迁移用户数据

如果您为用户存储了额外数据,则可采用数据迁移部分所述的策略将这些额外数据迁移到 Firebase Realtime Database。如果您按照帐号迁移部分所述的流程迁移帐号,那么您的 Firebase 帐号会使用与 Parse 帐号相同的 ID,方便您轻松迁移和复制由用户 ID 键控的任何关系。

Firebase Cloud Messaging

Firebase Cloud Messaging (FCM) 是一款跨平台消息传递解决方案,让您可以免费且可靠地传递消息和通知。Notifications Composer 是一项在 Firebase Cloud Messaging 的基础上构建的免费服务,可帮助移动应用开发者发送有针对性的用户通知。

如需了解详情,请参阅 Firebase Cloud Messaging 文档

与 Parse 推送通知的区别

在设备上安装并注册收取通知的每个 Parse 应用都有一个关联的 Installation 对象,您可在其中存储定位通知所需要的所有数据。InstallationParseUser 的一个子类,这意味着您可以向 Installation 实例添加所需的任何其他数据。

Notifications Composer 可根据应用、应用版本、设备语言等信息进行预定义的用户细分。您可以使用 Google Analytics(分析)事件和属性构建更复杂的用户细分,以创建受众群体。如需了解详情,请参阅受众群体帮助指南。这些定位信息不会出现在 Firebase Realtime Database 中。

建议的迁移策略

迁移设备令牌

在本文成文之时,Parse Android SDK 使用的是较早版本的 FCM 注册令牌,与 Notifications Composer 提供的功能不兼容。

您可以通过向自己的应用添加 FCM SDK 来获取新令牌,但是,这可能使 Parse SDK 用于接收通知的令牌失效。 如果您想避免发生这种情况,可以将 Parse SDK 设置为同时使用 Parse 的发送者 ID 和您的发送者 ID。这样,您就不会使 Parse SDK 使用的令牌失效,但请注意,如 Parse 关闭项目,这种解决方法会不再起作用。

将渠道迁移至 FCM 主题

如果您在使用 Parse 渠道发送通知,则可迁移至 FCM 主题,后者可以提供同样的“发布者-订阅者”模式。要处理从 Parse 向 FCM 的转变,您可编写一个新版本的应用,让新应用使用 Parse SDK 退订 Parse 渠道并使用 FCM SDK 订阅对应的 FCM 主题。在此版本的应用中,您应停用在 Parse SDK 上接收通知的功能,从您的应用清单中移除以下内容:

<service android:name="com.parse.PushService" />
<receiver android:name="com.parse.ParsePushBroadcastReceiver"
  android:exported="false">
<intent-filter>
<action android:name="com.parse.push.intent.RECEIVE" />
<action android:name="com.parse.push.intent.DELETE" />
<action android:name="com.parse.push.intent.OPEN" />
</intent-filter>
</receiver>
<receiver android:name="com.parse.GcmBroadcastReceiver"
  android:permission="com.google.android.c2dm.permission.SEND">
<intent-filter>
<action android:name="com.google.android.c2dm.intent.RECEIVE" />
<action android:name="com.google.android.c2dm.intent.REGISTRATION" />

<!--
IMPORTANT: Change "com.parse.starter" to match your app's package name.
-->
<category android:name="com.parse.starter" />
</intent-filter>
</receiver>

<!--
IMPORTANT: Change "YOUR_SENDER_ID" to your GCM Sender Id.
-->
<meta-data android:name="com.parse.push.gcm_sender_id"
  android:value="id:YOUR_SENDER_ID" />;

例如,如果您的用户订阅了“Giants”主题,您可执行如下所示的操作:

ParsePush.unsubscribeInBackground("Giants", new SaveCallback() {
    @Override
    public void done(ParseException e) {
        if (e == null) {
            FirebaseMessaging.getInstance().subscribeToTopic("Giants");
        } else {
            // Something went wrong unsubscribing
        }
    }
});

通过此策略,您可以向 Parse 渠道和相应的 FCM 主题发送消息,同时支持使用新版和旧版应用的用户。当有足够多的用户从 Parse 专用版应用迁移后,您就可以弃用该版本,开始只使用 FCM 发送消息。

如需了解详情,请参阅 FCM 主题文档

Firebase 远程配置

Firebase Remote Config 是一种云服务,让您可以更改应用的行为和外观,而无需用户下载应用更新。使用 Remote Config 时,您可以创建应用内默认值以控制应用的行为和外观。之后,您便可以使用 Firebase 控制台为所有应用用户或用户群细分替换应用内默认值。

如果您要在迁移过程中测试不同的解决方案,并希望能够将更多客户端动态转移到不同的服务提供方,Firebase Remote Config 就可以派上大用场。例如,如果您的某个应用版本同时使用了 Firebase 和 Parse 来处理数据,则您可以使用随机百分位规则来确定哪些客户端从 Firebase 读取数据,并逐渐扩大相应比例。

如需详细了解 Firebase Remote Config 功能,请参阅 Remote Config 简介

与 Parse 配置的差异

通过 Parse 配置,您可以在 Parse 配置信息中心向您的应用添加键值对,然后在客户端提取 ParseConfig。您得到的每个 ParseConfig 实例始终是不可变的。您将来从网络检索到新 ParseConfig 时,它不会修改任何现有 ParseConfig 实例,但会创建一个新实例并通过 getCurrentConfig() 提供该实例。

而使用 Firebase Remote Config 时,您会为键值对创建应用内默认值,并可从 Firebase 控制台重写这些默认值,这样一来,您可使用规则和条件向不同的细分用户群提供不同的应用用户体验。Firebase Remote Config 通过实现一个单例类,在您的应用中提供相应键值对。最初,单例类会返回您在应用内定义的默认值。之后,您可以随时在适当的时候为您的应用从服务器提取一组新值,成功提取该组新值后,您就可以选择在何时激活并向应用提供这些新值。

建议的迁移策略

迁移到 Firebase Remote Config 的方法是,将您的 Parse 配置的键值对复制到 Firebase 控制台,然后部署一个使用 Firebase Remote Config 的新版应用。

如果您想试验同时使用 Parse 配置和 Firebase Remote Config,可部署一个使用两种 SDK 的新版应用,直至有足够多的用户从 Parse 专用版本迁移为止。

代码比较

Parse

ParseConfig.getInBackground(new ConfigCallback() {
    @Override
    public void done(ParseConfig config, ParseException e) {
        if (e == null) {
            Log.d("TAG", "Yay! Config was fetched from the server.");
        } else {
            Log.e("TAG", "Failed to fetch. Using Cached Config.");
            config = ParseConfig.getCurrentConfig();
        }

        // Get the message from config or fallback to default value
        String welcomeMessage = config.getString("welcomeMessage", "Welcome!");
    }
});

Firebase

mFirebaseRemoteConfig = FirebaseRemoteConfig.getInstance();
// Set defaults from an XML resource file stored in res/xml
mFirebaseRemoteConfig.setDefaults(R.xml.remote_config_defaults);

mFirebaseRemoteConfig.fetch()
    .addOnSuccessListener(new OnSuccessListener<Void>() {
        @Override
        public void onSuccess(Void aVoid) {
            Log.d("TAG", "Yay! Config was fetched from the server.");
            // Once the config is successfully fetched it must be activated before newly fetched
            // values are returned.
            mFirebaseRemoteConfig.activateFetched();
        }
    })
    .addOnFailureListener(new OnFailureListener() {
        @Override
        public void onFailure(@NonNull Exception exception) {
            Log.e("TAG", "Failed to fetch. Using last fetched or default.");
        }
    })

// ...

// When this is called, the value of the latest fetched and activated config is returned;
// if there's none, the default value is returned.
String welcomeMessage = mFirebaseRemoteConfig.getString("welcomeMessage");