I have been struggling with what I believe is a screen repainting problem for a RecylerView when my underlying model is modified by another Thread. But have run out of ideas.
My application receives messages from a MQTT topic and displays it in a RecyclerView as a sort of "history" or "log" display.
This works fine as long as the MQTT session does not auto reconnect.
Once the MQTT session reconnects after a dropped connection, I still receive messages from the MQTT topic, the messages are still added to my model, I still raise the "Data changed" notification, I still invalidate the RecyclerView control, but the RecyclerView is no longer repainted to reveal the new message on screen.
If I manually force a refresh/repaint of the screen (e.g. scroll the recycler view, switch to another app and back again etc) then the RecyclerView is repainted and shows the "missing" messages.
My question is: what is it about the RecyclerView that seems to be causing it to not repaint when the underlying model is modified as a result of messages being received from an MQTT topic, but only if the MQTT session is dropped and reconnected?????
And obviously, what do I need to do to fix it?????
Update
I've tried adding the following method (which is activated by the onClick of a Floating button).
public void buttonClick (View v) {
mAdapter.add("Button Message");
Toast.makeText(getApplicationContext(),"Button message added", Toast.LENGTH_SHORT).show();
}
This method suffers from the same problem as messages received from the MQTT topic. If I click it before the MQTT auto-reconnect, the "Button Message" is added to my RecyclerView and displayed.
Once MQTT session is dropped and then auto reconnected, even this "buttonClick" method's "Button Message" is no longer displayed unless I force a refresh of the RecyclerList. FWIW, the "Toast" is always displayed (before and after the MQTT autoreconnect).
Could it be that I've stumbled upon some sort of wierd bug in RecyclerView???
FWIW 1, I've read many posts trying to get RecyclerView to work in relation to background thread updates of the underlying data model. Some suggest running the notification on the MainUI thread. I believe that this does make a difference. Previously it never displayed any of the messages received from the MQTT topic, now it does - but not if the connection is lost and reconnected.
FWIW 2, I know that the notifyDataSetChanged should be used as a last resort as it is least efficient. I have tried some of the other notify methods. These other notification methods produce the exact same problem as notifyDataSetChanged. At this point I am trying to keep it simple, so as to get it to work. Next I can focus on efficiency.
Here are the relevant code snippets.
Firstly, the MQTT callback which is invoked when a message is received:
@Override
public void messageArrived(final String topic, MqttMessage message) throws Exception {
final String msg = new String(message.getPayload());
if ("glennm/test/temp".equals(topic)) {
Log.i("RCVD", "Temperature: " + msg); // This code is shown to illustrate a control that
if (textViewTemperature != null) { // always seems to be redisplayed when a message is received
textViewTemperature.setText(msg + "°"); // even if the connection is lost and reconnected
textViewTemperature.invalidate();
temperatureHistory.add(msg);
temperatureHistory.dump("TEMP");
} else {
Log.e("NULL", "textView temperature control is null");
}
} else if ("glennm/test/humid".equals(topic)) {
// Code that updates the humidity text view omitted for brevity (as it is basically the same as the temperature code above.
} else { /***** This is the problem area - other messages logged to the Recycler view ****/
String wrk = topic;
if (topic != null && topic.toLowerCase().startsWith("glennm/test")) {
wrk = topic.substring(12);
}
final String topicToShow = wrk;
textViewOtherTopic.setText(topicToShow);
textViewOtherMessage.setText(msg);
// mAdapter.add(topicToShow + ": " + msg);
// The notify that the add method calls ***MUST*** be run on the main UI thread.
// Failure to do so means that the call will sometimes be ignored and the
// Recycler view is not updated to show the new incoming value.
// https://stackoverflow.com/questions/36467236/notifydatasetchanged-recyclerview-is-it-an-asynchronous-call/36512407#36512407
// This seems to help, but we still seem to have the same behaviour if the MQTT connection resets.
runOnUiThread(new Runnable() {
// recyclerView.post(new Runnable() {
@Override
public void run() {
mAdapter.add(topicToShow + ": " + msg);
recyclerView.invalidate();
}
});
Log.i("RCVD", "Other Topic: " + topic + ", message: " + msg);
}
}
}
Secondly, the code that is invoked to add the message to the underlying data model (and notify the UI to repaint it).
public void add(String msg) {
Log.d("HISTORY", "Adding message: " + msg);
messageList.add(msg);
while (messageList.size() > MAX_HISTORY) {
messageList.remove(0);
}
Log.d("HISTORY", "Notifying data set changed");
_parent.runOnUiThread(new Runnable () {
@Override
public void run() {
notifyDataSetChanged();
}
});
Log.d("HISTORY", "Notifying data set changed - complete");
}
Finally, here are three screen snapshots which try to illustrate the problem. In the first, messages have been received from the MQTT topic and are displayed both in the journal and the "current message" field (textViewOtherTopic and textViewOtherMessage controls) located below the humidity.
What happens between the first and second screen shot is that the MQTT service lost its connection and auto-reconnected. After that, the received message is only displayed in the "current message" view and not the "message log" recycler view (despite being added to the model).
Only when the Recycler View is forced to be repainted by an external (manual) user action (e.g. scrolling) does the missing message(s) show up.
Here is an example of a command using the mosquitto mqtt client that is used to post a message to the app:
mosquitto_pub -h test.mosquitto.org -t "glennm/test/comment" -q 1 -m "It's a new day, but still cold! 3"
Following is the full code for the two classes (including many of the commented out attempts I've made)...
The Main activity:
package com.gtajb.tempmonitorapp;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.os.Bundle;
import android.util.Log;
import android.widget.TextView;
import org.eclipse.paho.android.service.MqttAndroidClient;
import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions;
import org.eclipse.paho.client.mqttv3.IMqttActionListener;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.IMqttMessageListener;
import org.eclipse.paho.client.mqttv3.IMqttToken;
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import java.util.ArrayList;
import java.util.UUID;
public class MainActivity extends AppCompatActivity {
private MqttAndroidClient mqttAndroidClient;
private String serverUri = "tcp://test.mosquitto.org:1883";
public static final String clientId = UUID.randomUUID().toString();
public final String subscriptionTopic = "glennm/test/#";
public final String publishTopic = "glennm/test/tome";
public final String publishMessage = "Hello from Android test client";
private TextView textViewTemperature;
private TextView textViewHumidity;
private TextView textViewOtherTopic;
private TextView textViewOtherMessage;
private MessageCallBack messageCallBack = new MessageCallBack();
private RecyclerView recyclerView;
private MessageHistory mAdapter;
private RecyclerView.LayoutManager layoutManager;
private ReadingsHistory temperatureHistory = new ReadingsHistory();
private ReadingsHistory humidityHistory = new ReadingsHistory();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Log.d("INFO", "Createing the mqtt client with client ID: " + clientId);
mqttAndroidClient = new MqttAndroidClient(getApplicationContext(), serverUri, clientId);
mqttAndroidClient.setCallback(new MqttClientCallback());
MqttConnectOptions mqttConnectOptions = new MqttConnectOptions();
mqttConnectOptions.setAutomaticReconnect(true);
mqttConnectOptions.setCleanSession(false);
textViewTemperature = findViewById(R.id.temperatureValueLabel);
textViewTemperature.setText("Temp goes here");
textViewHumidity = findViewById(R.id.humidtyValueLabel);
textViewHumidity.setText("Humid goes here");
textViewOtherTopic = findViewById(R.id.otherTopicValueLabel);
textViewOtherMessage = findViewById(R.id.otherMessageValueLabel);
textViewOtherTopic.setText(".");
textViewOtherMessage.setText(".");
recyclerView = findViewById(R.id.historyPanel);
mAdapter = new MessageHistory(new ArrayList<String>(), this, recyclerView);
layoutManager = new LinearLayoutManager(this);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAdapter(mAdapter);
mAdapter.add("A test message");
messageCallBack = new MessageCallBack();
try {
mqttAndroidClient.connect(mqttConnectOptions, null, new IMqttActionListener() {
@Override
public void onSuccess(IMqttToken asyncActionToken) {
DisconnectedBufferOptions disconnectedBufferOptions = new DisconnectedBufferOptions();
disconnectedBufferOptions.setBufferEnabled(true);
disconnectedBufferOptions.setBufferSize(100);
disconnectedBufferOptions.setPersistBuffer(false);
disconnectedBufferOptions.setDeleteOldestMessages(false);
mqttAndroidClient.setBufferOpts(disconnectedBufferOptions);
//subscribeToTopic();
}
@Override
public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
Log.e("CONNECT", "Failed to connect to " + serverUri);
Log.e("CONNECT", exception.getMessage());
}
});
} catch (MqttException e) {
Log.e("CONNECT", "Exception connecting to " + serverUri);
Log.e("CONNECT", e.getMessage());
Log.e("CONNECT", Log.getStackTraceString(e));
e.printStackTrace();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
Log.i("CONN", "Closing MQTT connection");
mqttAndroidClient.close();
}
public class MqttClientCallback implements MqttCallbackExtended {
@Override
public void connectComplete(boolean reconnect, String serverURI) {
if (reconnect) {
Log.i("CONN", "Reconnected to: " + serverURI);
subscribeToTopic();
} else {
Log.i("CONN", "Connected to: " + serverURI);
subscribeToTopic();
}
}
@Override
public void connectionLost(Throwable cause) {
Log.i("CONN", "Connection lost");
}
@Override
public void messageArrived(String topic, MqttMessage message) throws Exception {
Log.i("MSG", topic + " - " + new String(message.getPayload()));
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
Log.i("PUB", "Delivery complete");
}
}
public void subscribeToTopic() {
try {
Log.i("TOPIC", "Subscribing to: " + subscriptionTopic);
// mqttAndroidClient.subscribe(subscriptionTopic, 0, null, new IMqttActionListener() {
// @Override
// public void onSuccess(IMqttToken asyncActionToken) {
// Log.i("SUBS", "Subscription to " + subscriptionTopic + " on " + serverUri + " successful");
// }
//
// @Override
// public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
// Log.i("SUBS", "Subscription to " + subscriptionTopic + " on " + serverUri + " FAILED");
// }
// });
mqttAndroidClient.subscribe(subscriptionTopic, 0, messageCallBack);
} catch (MqttException e) {
Log.e("SUBS", "Failed to subscribe to topic: " + subscriptionTopic + " on " + serverUri);
Log.e("SUBS", e.getMessage());
}
}
public void setTemperatureValue(String val) {
textViewTemperature.setText(val);
}
public void setHumidityValue(String val) {
textViewHumidity.setText(val);
}
public class MessageCallBack implements IMqttMessageListener , IMqttActionListener {
@Override
public void onSuccess(IMqttToken asyncActionToken) {
Log.i("MQTT", "Successful operation " + asyncActionToken.toString());
textViewTemperature.setText("Subscribed");
}
@Override
public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
Log.i("MQTT", "Un Successful operation + " + asyncActionToken.toString());
textViewTemperature.setText("Not Subscribed");
}
@Override
public void messageArrived(final String topic, MqttMessage message) throws Exception {
final String msg = new String(message.getPayload());
if ("glennm/test/temp".equals(topic)) {
Log.i("RCVD", "Temperature: " + msg);
if (textViewTemperature != null) {
textViewTemperature.setText(msg + "°");
textViewTemperature.invalidate();
temperatureHistory.add(msg);
temperatureHistory.dump("TEMP");
} else {
Log.e("NULL", "textView temperature control is null");
}
} else if ("glennm/test/humid".equals(topic)) {
Log.i("RCVD", "Humidity: " + msg);
textViewHumidity.setText(msg + "%");
textViewHumidity.invalidate();
humidityHistory.add(msg);
humidityHistory.dump("HUMID");
} else {
String wrk = topic;
if (topic != null && topic.toLowerCase().startsWith("glennm/test")) {
wrk = topic.substring(12);
}
final String topicToShow = wrk;
textViewOtherTopic.setText(topicToShow);
textViewOtherMessage.setText(msg);
// mAdapter.add(topicToShow + ": " + msg);
// The notify that the add method calls ***MUST*** be run on the main UI thread.
// Failure to do so means that the call will sometimes be ignored and the
// Recycler view is not updated to show the new incoming value.
// https://stackoverflow.com/questions/36467236/notifydatasetchanged-recyclerview-is-it-an-asynchronous-call/36512407#36512407
// This seems to help, but we still seem to have the same behaviour.
runOnUiThread(new Runnable() {
// recyclerView.post(new Runnable() {
@Override
public void run() {
mAdapter.add(topicToShow + ": " + msg);
// recyclerView.invalidate();
}
});
Log.i("RCVD", "Other Topic: " + topic + ", message: " + msg);
// Context context = getApplicationContext();
// Toast msgPopup = Toast.makeText(context, msg, Toast.LENGTH_SHORT);
// msgPopup.show();
}
}
}
}
The Message History class:
package com.gtajb.tempmonitorapp;
import android.app.Activity;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
public class MessageHistory extends RecyclerView.Adapter<MessageHistory.Callback> {
private ArrayList<String> messageList = new ArrayList();
public static final int MAX_HISTORY = 100;
private Activity _parent;
private RecyclerView rv;
public class Callback extends RecyclerView.ViewHolder {
TextView mTextView;
Callback(View itemView) {
super(itemView);
mTextView = itemView.findViewById(R.id.row_text);
}
}
public MessageHistory(ArrayList<String> messageList, Activity parent, RecyclerView rv) {
super();
this.messageList = messageList;
this._parent = parent;
this.rv = rv;
add("Test Message 1");
add("Test Message 2");
}
@NonNull
@Override
public Callback onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.history_row, parent, false);
return new Callback(v);
}
@Override
public void onBindViewHolder(@NonNull Callback holder, int position) {
Log.d("HISTORY", "Setting " + position + ": " + messageList.get(position));
holder.mTextView.setText(messageList.get(position));
}
@Override
public int getItemCount() {
Log.d("HISTORY", "Message Count: " + messageList.size());
return messageList.size();
}
/**
* Add a message to the message log.
* @param msg the message to add.
*/
public void add(String msg) {
Log.d("HISTORY", "Adding message: " + msg);
messageList.add(msg);
while (messageList.size() > MAX_HISTORY) {
messageList.remove(0);
}
// getItemCount();
Log.d("HISTORY", "Notifying data set changed");
_parent.runOnUiThread(new Runnable () {
@Override
public void run() {
notifyDataSetChanged();
}
});
//this.notifyDataSetChanged();
Log.d("HISTORY", "Notifying data set changed - complete");
// rv.invalidate();
// rv.refreshDrawableState();
// this.notifyItemInserted(messageList.size());
// final RecyclerView.Adapter adapter = this;
// _parent.runOnUiThread(new Runnable() {
// @Override
// public void run() {
// adapter.notifyDataSetChanged();
// }
// });
}
}