Статьи

Создание приложения чата с кодовым названием One Part 5

Пользовательский интерфейс чата — это то, над чем мы работаем, и в сегодняшнем посте мы собираемся построить именно это!

Еще лучше… Мы интегрируемся с Pubnub, чтобы сделать приложение почти полностью функциональным в качестве элементарного приложения для чата, что довольно впечатляюще. В этом разделе мы рассмотрим пользовательский интерфейс, хранилище (экстернализацию), Pubnub и его JSON API … Мы также будем использовать InteractionDialog для отображения уведомлений о входящих сообщениях …

Прежде чем мы начнем, вам нужно будет войти на pubnub.com и зарегистрировать учетную запись, где вы получите два идентификатора, необходимые для подписки и отправки.

Нам также нужно установить Pubnub cn1lib и его зависимости, разместить следующие файлы в каталоге lib в иерархии проекта: BouncyCastleCN1Lib.cn1lib , Pubnub-CodeNameOne-3.7.4.cn1lib & json.cn1lib .

Как только вы поместите файлы в каталог lib, щелкните правой кнопкой мыши проект и выберите «Codename One → Refresh Libs». Это установит библиотеки в ваш путь к классам и позволит вам использовать их, наслаждаясь такими функциями, как завершение кода и т. Д.

Вам также понадобятся изображения для пузырей чата, а именно:

чат-пузырь левый

И этот :

чат-пузырь правый

Изменения темы

Нам нужно начать с настройки элементов темы, которые мы будем использовать позже для пузырькового чата, нам фактически нужны два речевых пузырька, упомянутых выше, для сопоставления с BubbleMe & BubbleThem . Итак, начнем с добавления элемента темы BubbleMe котором мы устанавливаем прозрачность на 0 (поскольку мы будем использовать границу изображения) и цвет переднего плана на белый ffffff :

чат-приложение-учебник-чат-форма-1

Затем нам нужно установить отступ для речевого пузыря, чтобы текст не был сверху речевой стрелки или самой границы. Мы устанавливаем отступ в миллиметрах, чтобы дизайн был портативным, а слева — 3 мм, чтобы оставить место для стрелки:

чат-приложение-учебник-чат-форма-2

Теперь нам нужно обрезать границу изображения, используя мастер границ изображения и изображение chat-bubble-left.png мы упоминали ранее. Обратите внимание, что мы берем линии как можно ближе друг к другу, чтобы граница работала:

чат-приложение-учебник-чат-форма-3

Нам нужно сделать то же самое для BubbleThem BubbleThem, с единственным отличием — chat-bubble-right.png и большим заполнением справа, а не слева.

чат-приложение-учебник-чат-форма-4

Класс сообщения

До сих пор мы использовали в основном внутренние классы в одном файле, что довольно просто для демонстрации. Но теперь мы добавим новый класс Message который будет представлять отправленное / полученное сообщение и инкапсулировать логику синтаксического анализа JSON, необходимую для связи с PubNub.

Этот класс является Externalizeable что означает, что мы можем относительно легко сохранить его в хранилище. Важно сохранить прошлые сообщения в разговоре:

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
public class Message implements Externalizable {
 
    private long time;
    private String senderId;
    private String recepientId;
    private String picture;
    private String name;
    private String message;
 
    /**
     * Required default constructor for externalizable to work...
     */
    public Message() {}
 
    public Message(String senderId, String recepientId, String picture, String name, String message) {
        this.senderId = senderId;
        this.recepientId = recepientId;
        this.picture = picture;
        this.name = name;
        this.message = message;
    }
 
    public Message(JSONObject obj) {
        try {
            time = Long.parseLong(obj.getString("time"));
            senderId = obj.getString("fromId");
            recepientId = obj.getString("toId");
            message = obj.getString("message");
            name = obj.getString("name");
            picture = obj.getString("pic");
        } catch (JSONException ex) {
            // will this ever happen?
            Log.e(ex);
        }
    }
 
    public JSONObject toJSON() {
        JSONObject obj = createJSONObject("fromId", senderId,
                "toId", recepientId,
                "name", name,
                "pic", picture,
                "time", Long.toString(System.currentTimeMillis()),
                "message", message);
        return obj;
    }
 
    /**
     * Helper method to create a JSONObject
     */
    JSONObject createJSONObject(String... keyValues) {
        try {
            JSONObject o = new JSONObject();
            for(int iter = 0 ; iter < keyValues.length ; iter += 2) {
                o.put(keyValues[iter], keyValues[iter + 1]);
            }
            return o;
        } catch(JSONException err) {
            // will this ever happen?
            err.printStackTrace();
        }
        return null;
    }
 
 
    @Override
    public int getVersion() {
        return 1;
    }
 
    @Override
    public void externalize(DataOutputStream out) throws IOException {
        out.writeLong(time);
        Util.writeUTF(senderId, out);
        Util.writeUTF(recepientId, out);
        Util.writeUTF(picture, out);
        Util.writeUTF(name, out);
        Util.writeUTF(message, out);
    }
 
    @Override
    public void internalize(int version, DataInputStream in) throws IOException {
        time = in.readLong();
        senderId = Util.readUTF(in);
        recepientId = Util.readUTF(in);
        picture = Util.readUTF(in);
        name = Util.readUTF(in);
        message = Util.readUTF(in);
    }
 
    @Override
    public String getObjectId() {
        return "Message";
    }
 
    public long getTime() {
        return time;
    }
 
    public String getSenderId() {
        return senderId;
    }
 
    public String getRecepientId() {
        return recepientId;
    }
 
    public String getPicture() {
        return picture;
    }
 
    public String getName() {
        return name;
    }
 
    public String getMessage() {
        return message;
    }
}

Этот класс довольно прост, обратите внимание на несколько интересных вещей об экстернализации:

  • Мы используем Util.writeUTF и Util.readUTF который добавляет поддержку пустых строк, сначала записывая / читая логическое значение, чтобы указать, является ли значение нулевым…
  • getObjectId — это жестко закодированная строка, а не что-то вроде getClass().getName() . Использование имени класса — очень распространенная ошибка, поэтому я упоминаю об этом. Он будет работать в симуляторе и, похоже, будет работать во время разработки, но не сможет обновиться, поскольку имена классов на некоторых устройствах запутаны и могут привести к серьезным проблемам.
  • Нам также нужен конструктор по умолчанию для поддержки экстернализации.

Глобальные переменные и инициализация

Для начала нам нужно добавить несколько глобальных переменных:

1
2
3
private Pubnub pb;
private Image roundedMeImage;
private final WeakHashMap<String, EncodedImage> roundedImagesOfFriends = new WeakHashMap<>();

Это должно быть довольно понятно, pubnub представляет собой API для push. RoundedMeImage — это кэшированная версия изображения, которое мы создали ранее. Это позволяет нам повторно использовать этот элемент пользовательского интерфейса в различных формах. WeakHashMap позволяет нам кэшировать фотографии друзей, не вызывая утечку памяти …

Нам также нужно добавить это в метод init :

1
2
3
4
5
public void init(Object context) {
    ...
    Util.register("Message", Message.class);
    ...
}

Это эффективно регистрирует класс Message в системе, поэтому при десериализации его при загрузке мы можем распознать класс. Разработчики часто делают ошибку, используя статический код инициализатора в классе, чтобы зарегистрировать себя. Это проблематично, поскольку класс не может быть загружен до его чтения.

Прослушивание сообщений

Мы хотели бы начать прослушивать входящие сообщения, как только мы вошли в систему, и это происходит в showContactsForm чтобы мы могли добавить этот вызов в начало этого метода:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
void showContactsForm(UserData data) {
    listenToMessages();
    ...
}
 
private void listenToMessages() {
    try {
        pb = new Pubnub("pub-c-*********-****-****-****-*************", "sub-c-*********-****-****-****-*************");
        pb.subscribe(tokenPrefix + uniqueId, new Callback() {
            @Override
            public void successCallback(String channel, Object message, String timetoken) {
                    Display.getInstance().callSerially(() -> {
                        respond(new Message((JSONObject)message));
                    });
            }
        });
    } catch(PubnubException err) {
        Log.e(err);
        Dialog.show("Error", "There was a communication error: " + err, "OK", null);
    }
}

Подписка на сообщения через pubnub тривиальна, мы конвертируем объект JSON, полученный ответом, и отправляем его методу, который отправляет ответ. Обратите внимание, что мы обертываем вызов в вызов последовательно, так как ответ получен от EDT, и обработка его, вероятно, должна быть в EDT, поскольку мы будем взаимодействовать с пользовательским интерфейсом. Мы углубимся в обработку ответов позже …

PubNub работает, предоставляя очереди сообщений, на которые вы можете подписаться и опубликовать, представьте себе это как электронную почту, где вы можете подписаться на список рассылки и обрабатывать входящие сообщения. Однако, если вы не слушаете, сообщение может пропасть… Они предоставляют опцию для постоянной очереди, в которой будут храниться последние 100 сообщений, что может быть очень полезно для такого рода приложений!

Архитектура, которую мы собираемся использовать, довольно проста, каждый человек просто слушает свой уникальный идентификатор. Таким образом, чтобы отправить сообщение конкретному человеку, просто опубликуйте его в очереди, и этот человек сможет ответить, опубликовав свой.

Мы включаем информацию об отправителе в каждое сообщение, что позволяет нам отличать людей, с которыми мы общаемся.

Форма чата

чат-приложение-учебник-чат-форма-5

Это скриншот из чата на моем Android-устройстве … Форма чата создается с помощью этого метода, который немного многословен, но относительно прост.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
void showChatForm(ContactData d, Component source) {
    Form chatForm = new Form(d.name);
 
    // this identifies the person we are chatting with, so an incoming message will know if this is the right person...
    chatForm.putClientProperty("cid", tokenPrefix + d.uniqueId);
    chatForm.setLayout(new BorderLayout());
    Toolbar tb = new Toolbar();
    final Container chatArea = new Container(new BoxLayout(BoxLayout.Y_AXIS));
    chatArea.setScrollableY(true);
    chatArea.setName("ChatArea");
    chatForm.setToolBar(tb);
    chatForm.setBackCommand(new Command("Contacts") {
        @Override
        public void actionPerformed(ActionEvent evt) {
            source.getComponentForm().showBack();
        }
    });
 
    // Provides the ability to swipe the screen to go back to the previous form
    SwipeBackSupport.bindBack(chatForm, (args) -> {
        return source.getComponentForm();
    });
 
    // Gets a rounded version of our friends picture and caches it
    Image roundedHimOrHerImage = getRoundedFriendImage(d.uniqueId, d.imageUrl);
 
    // load the stored messages and add them to the form
    java.util.List<Message> messages = (java.util.List<Message>)Storage.getInstance().readObject(tokenPrefix + d.uniqueId);
    if(messages != null) {
        for(Message m : messages) {
            if(m.getRecepientId().equals(tokenPrefix + uniqueId)) {
                respondNoLayout(chatArea, m.getMessage(), roundedHimOrHerImage);
            } else {
                sayNoLayout(chatArea, m.getMessage());
            }
        }
    }
 
    // to place the image on the right side of the toolbar we just use a command that does nothing...
    Command himOrHerCommand = new Command("", roundedHimOrHerImage);
    tb.addCommandToRightBar(himOrHerCommand);
 
    // we type the message to the chat partner in the text field on the south side
    TextField write = new TextField(30);
    write.setHint("Write to " + d.name);
    chatForm.addComponent(BorderLayout.CENTER, chatArea);
    chatForm.addComponent(BorderLayout.SOUTH, write);
 
    // the action listener for the text field creates a message object, converts it to JSON and publishes it to the listener queue
    write.addActionListener((e) -> {
        String text = write.getText();
        final Component t = say(chatArea, text);
 
        // we make outgoing messages translucent to indicate that they weren't received yet
        t.getUnselectedStyle().setOpacity(120);
        write.setText("");
 
        final Message messageObject = new Message(tokenPrefix + uniqueId, tokenPrefix + d.uniqueId, imageURL, fullName, text);
        JSONObject obj = messageObject.toJSON();
 
        pb.publish(tokenPrefix + d.uniqueId, obj, new Callback() {
            @Override
            public void successCallback(String channel, Object message) {
                // a message was received, we make it opauqe and add it to the storage
                t.getUnselectedStyle().setOpacity(255);
                addMessage(messageObject);
            }
 
            @Override
            public void errorCallback(String channel, PubnubError error) {
                chatArea.removeComponent(t);
                chatArea.revalidate();
                Dialog.show("Error", "Connection error message wasn't sent", "OK", null);
            }
        });
    });
 
    chatForm.show();
}

Есть несколько вещей, представляющих интерес в вышеуказанном методе:

  • chatArea содержит все записи чата, обратите внимание, мы даем ему имя явно! Это полезно позже, когда приходит сообщение чата, если мы находимся в форме чата, мы хотели бы добавить сообщение в эту форму …
  • Мы используем свойство клиента в форме чата cid для хранения пользователя, с которым мы общаемся. Таким образом, если мы общаемся с другим контактом, входящее сообщение от другого контакта не будет подталкиваться к этому разговору.
  • Область чата прокручивается, а текстовое поле находится на юге. Обратите внимание, что, поскольку мы установили макет на макет границы, прокручиваемость по умолчанию панели содержимого формы была неявно отключена.
  • Мы использовали команду, которая ничего не делает, чтобы поместить изображение контакта на панель инструментов, она проще, чем эффект, который мы имели в предыдущей форме, но все же довольно симпатичная.
  • Мы загружаем существующие сообщения, если они доступны из хранилища, используя возможность экстернализации объектов класса Storage .
  • По умолчанию у нас есть методы say и respond которые инкапсулируют создание компонента пузырькового чата. Тем не менее, они делают приятную входящую анимацию, которая нам не нужна, когда приходит новое сообщение, поэтому мы используем версию, которая не делает макет для обоих при создании формы.
  • Это весь код, который вам нужен для работы с PubNub! Это довольно круто … Все остальное для нас.

Есть несколько важных методов, используемых этим методом, и мы рассмотрим их один за другим:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
private Component say(Container chatArea, String text) {
    Component t = sayNoLayout(chatArea, text);
    t.setY(chatArea.getHeight());
    t.setWidth(chatArea.getWidth());
    t.setHeight(40);
    chatArea.animateLayoutAndWait(300);
    chatArea.scrollComponentToVisible(t);
    return t;
}
 
private Component sayNoLayout(Container chatArea, String text) {
    SpanLabel t = new SpanLabel(text);
    t.setIcon(roundedMeImage);
    t.setTextBlockAlign(Component.LEFT);
    t.setTextUIID("BubbleMe");
    chatArea.addComponent(t);
    return t;
}

Эти два метода, по сути, распечатывают то, что мы должны сказать как пузырь в чате. Последний метод просто устанавливает значок метки диапазона на мое изображение (которое мы сделали ранее) и выравнивает блок по левому краю. Он также устанавливает пузырь UIID, который мы создали ранее, в текстовую часть метки диапазона.

Первый метод позволяет анимировать пузырь снизу, устанавливая размер / расположение компонента, а затем создавая анимированный макет, который направляет его в нужное место.

Основные методы ответа практически идентичны с некоторыми незначительными изменениями:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
private void respond(Container chatArea, String text, Image roundedHimOrHerImage) {
    Component answer = respondNoLayout(chatArea, text, roundedHimOrHerImage);
    answer.setX(chatArea.getWidth());
    answer.setWidth(chatArea.getWidth());
    answer.setHeight(40);
    chatArea.animateLayoutAndWait(300);
    chatArea.scrollComponentToVisible(answer);
}
 
private Component respondNoLayout(Container chatArea, String text, Image roundedHimOrHerImage) {
    SpanLabel answer = new SpanLabel(text);
    answer.setIcon(roundedHimOrHerImage);
    answer.setIconPosition(BorderLayout.EAST);
    answer.setTextUIID("BubbleThem");
    answer.setTextBlockAlign(Component.RIGHT);
    chatArea.addComponent(answer);
    return answer;
}

Основным отличием здесь является UIID и выравнивание по правому краю, также вы заметите, что мы передаем изображение говорящего в качестве аргумента, поскольку оно может быть недоступно …

Но большая разница в том, что эти методы не вызываются напрямую для входящих записей чата, вместо этого мы используем:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private void respond(Message m) {
    String clientId = (String)Display.getInstance().getCurrent().getClientProperty("cid");
    addMessage(m);
    EncodedImage rounded = getRoundedFriendImage(m.getSenderId(), m.getPicture());
    if(clientId == null || !clientId.equals(m.getSenderId())) {
        // show toast, we aren't in the chat form...
        InteractionDialog toast = new InteractionDialog();
        toast.setUIID("Container");
        toast.setLayout(new BorderLayout());
 
        SpanButton messageButton = new SpanButton(m.getMessage());
        messageButton.setIcon(rounded);
 
        toast.addComponent(BorderLayout.CENTER, messageButton);
        int h = toast.getPreferredH();
        toast.show(Display.getInstance().getDisplayHeight() - h - 10, 10, 10, 10);
        UITimer uit = new UITimer(() -> {
            toast.dispose();
        });
        uit.schedule(3000, false, Display.getInstance().getCurrent());
 
        messageButton.addActionListener((e) -> {
            uit.cancel();
            toast.dispose();
            showChatForm(getContactById(m.getSenderId()), Display.getInstance().getCurrent());
        });
    } else {
        Container chatArea = getChatArea(Display.getInstance().getCurrent().getContentPane());
        respond(chatArea, m.getMessage(), rounded);
    }
}

Как вы помните из первоначального вызова подписки, этот метод вызывается изнутри, когда сообщение поступает из pubnub. В этот момент мы вошли в систему, но мы можем общаться в чате с кем-то еще, или мы можем даже быть в форме контактов в одном из этих двух случаев, идентификатор будет нулевым или отличным от текущего идентификатора, поэтому мы покажем взаимодействие эффект диалога, который вы можете увидеть в видео ниже, в противном случае мы просто вызываем метод обычного ответа, который мы видели выше:

Диалог взаимодействия — это просто контейнер, размещенный на многоуровневой панели. Из-за этого он не блокирует ввод, как это делает обычный диалог, поэтому, если я общаюсь с кем-то, когда приходит сообщение, это не должно вызывать проблем. Мы используем UITimer ` to automatically dispose of the dialog, the `UITimer удобен, так как он вызывается в EDT в отличие от обычного таймера, поэтому усилия минимальны.

Мы используем кнопку для уведомления, чтобы мы могли щелкнуть ее и перейти непосредственно к окну чата, как показано в конце видео выше.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
private Container getChatArea(Container cnt) {
    String n = cnt.getName();
    if(n != null && n.equals("ChatArea")) {
        return cnt;
    }
 
    for(Component cmp : cnt) {
        if(cmp instanceof Container) {
            Container cur = getChatArea((Container)cmp);
            if(cur != null) {
                return cur;
            }
        }
    }
    return null;
}

Вы заметите, что мы использовали getChatArea как простой инструмент для абстрагирования области чата. Мы также можем сохранить ссылку на chatArea в самом классе, но это может привести к утечке памяти, так что это проще. Я не слишком обеспокоен темами или условиями гонки, так как почти все на EDT.

1
2
3
4
5
6
7
8
private EncodedImage getRoundedFriendImage(String uid, String imageUrl) {
    EncodedImage roundedHimOrHerImage = roundedImagesOfFriends.get(uid);
    if(roundedHimOrHerImage == null) {
        roundedHimOrHerImage = URLImage.createToStorage(roundPlaceholder, "rounded" + uid, imageUrl, URLImage.createMaskAdapter(mask));
        roundedImagesOfFriends.put(uid, roundedHimOrHerImage);
    }
    return roundedHimOrHerImage;
}

Мы упоминали об этом методе гораздо раньше, он относительно прост, поэтому я замял его. Это примерно идентично «мне изображение», которое мы имели в предыдущих постах, только что примененных к фотографиям друзей.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
private void addMessage(Message m) {
    String personId;
 
    // if this is a message to me then store based on sender otherwise store based on recepient
    if(m.getRecepientId().equals(tokenPrefix + uniqueId)) {
        personId = m.getSenderId();
    } else {
        personId = m.getRecepientId();
    }
    java.util.List messages = (java.util.List)Storage.getInstance().readObject(personId);
    if(messages == null) {
        messages = new ArrayList();
    }
    messages.add(m);
    Storage.getInstance().writeObject(personId, messages);
}

Последний интересующий метод сохраняет данные сообщения в хранилище. Мы используем массив сообщений, который довольно прост.

Потенциальные улучшения

В следующий раз мы обсудим push-сообщения, в этой версии приложения сообщения, которые не приходят, теряются … Это проблема. В этом случае мы будем использовать push-уведомление от ОС, чтобы предупредить другую сторону.

Мы также можем включить хранилище для очереди сообщений, что позволит сохранять сообщения в кэше до следующего входа пользователя в систему.

Существует также проблема, если пользователь входит в систему с нескольких устройств, он не видит историю чата, и одно устройство будет захватывать входящие сообщения, которые могут не достигнуть другого … Это можно легко решить с помощью архитектуры центральной базы данных или, возможно, постоянных очередей в pubnub (хотя у меня нет опыта там).

Другие потенциальные улучшения могут быть:

  • Facebook приглашать друзей и делиться кнопками
  • Непрочитанный счетчик и значок для iOS
  • Записи панели уведомлений
  • Вложения и более сложные данные

Все это должно быть довольно тривиально. Мы опубликуем полный исходный код и проект со следующим обновлением этой серии, и вы, ребята, поиграете с ним.

Другие сообщения в этой серии

Это непрерывная серия постов, включающая следующие части: